page.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. "use client";
  2. import { useState, useEffect, useCallback } from "react";
  3. import { Header } from "@/components/layout";
  4. import { AppLayout } from "@/components/layout";
  5. import { Button } from "@/components/ui/button";
  6. import { Card, CardContent } from "@/components/ui/card";
  7. import { Badge } from "@/components/ui/badge";
  8. import { apiClient, type JobDetailsResponse } from "@/lib/api";
  9. import {
  10. Loader2,
  11. Download,
  12. RefreshCw,
  13. Calendar,
  14. Image as ImageIcon,
  15. X,
  16. ZoomIn,
  17. } from "lucide-react";
  18. import { downloadAuthenticatedImage } from "@/lib/utils";
  19. interface GalleryImage {
  20. jobId: string;
  21. filename: string;
  22. url: string;
  23. thumbnailUrl: string;
  24. prompt?: string;
  25. negativePrompt?: string;
  26. width?: number;
  27. height?: number;
  28. steps?: number;
  29. cfgScale?: number;
  30. seed?: string;
  31. model?: string;
  32. createdAt: string;
  33. status: string;
  34. }
  35. interface ImageModalProps {
  36. image: GalleryImage | null;
  37. isOpen: boolean;
  38. onClose: () => void;
  39. }
  40. function ImageModal({ image, isOpen, onClose }: ImageModalProps) {
  41. if (!isOpen || !image) return null;
  42. const handleBackdropClick = (e: React.MouseEvent) => {
  43. if (e.target === e.currentTarget) {
  44. onClose();
  45. }
  46. };
  47. return (
  48. <div
  49. className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
  50. onClick={handleBackdropClick}
  51. >
  52. <div className="relative max-w-[90vw] max-h-[90vh] flex flex-col">
  53. {/* Header with image info */}
  54. <div className="bg-background border-b border-border p-4 rounded-t-lg">
  55. <div className="flex items-center justify-between">
  56. <div className="flex items-center gap-2">
  57. <ImageIcon className="h-5 w-5" />
  58. <span className="font-medium">Generated Image</span>
  59. <Badge variant="secondary">{image.model || "Unknown"}</Badge>
  60. </div>
  61. <Button
  62. size="icon"
  63. variant="ghost"
  64. onClick={onClose}
  65. className="h-8 w-8"
  66. >
  67. <X className="h-4 w-4" />
  68. </Button>
  69. </div>
  70. {/* Image metadata */}
  71. <div className="mt-2 text-sm text-muted-foreground grid grid-cols-2 md:grid-cols-4 gap-2">
  72. {image.width && image.height && (
  73. <span>
  74. Size: {image.width}×{image.height}
  75. </span>
  76. )}
  77. {image.steps && <span>Steps: {image.steps}</span>}
  78. {image.cfgScale && <span>CFG: {image.cfgScale}</span>}
  79. {image.seed && <span>Seed: {image.seed}</span>}
  80. </div>
  81. {image.prompt && (
  82. <div className="mt-2">
  83. <p className="text-sm font-medium">Prompt:</p>
  84. <p className="text-sm text-muted-foreground line-clamp-2">
  85. {image.prompt}
  86. </p>
  87. </div>
  88. )}
  89. </div>
  90. {/* Image container */}
  91. <div className="flex items-center justify-center bg-muted/50 p-4 rounded-b-lg">
  92. <img
  93. src={image.url}
  94. alt="Generated image"
  95. className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-lg"
  96. />
  97. </div>
  98. {/* Download button */}
  99. <div className="absolute bottom-4 right-4">
  100. <Button
  101. size="sm"
  102. onClick={() => {
  103. const authToken = localStorage.getItem("auth_token");
  104. const unixUser = localStorage.getItem("unix_user");
  105. downloadAuthenticatedImage(
  106. image.url,
  107. `gallery-${image.jobId}-${image.filename}`,
  108. authToken || undefined,
  109. unixUser || undefined,
  110. );
  111. }}
  112. >
  113. <Download className="h-4 w-4 mr-2" />
  114. Download
  115. </Button>
  116. </div>
  117. </div>
  118. </div>
  119. );
  120. }
  121. function GalleryGrid() {
  122. const [images, setImages] = useState<GalleryImage[]>([]);
  123. const [loading, setLoading] = useState(true);
  124. const [error, setError] = useState<string | null>(null);
  125. const [selectedImage, setSelectedImage] = useState<GalleryImage | null>(null);
  126. const [isModalOpen, setIsModalOpen] = useState(false);
  127. const [sortBy, setSortBy] = useState<"newest" | "oldest">("newest");
  128. const loadGalleryImages = useCallback(async () => {
  129. try {
  130. setLoading(true);
  131. setError(null);
  132. console.log("Gallery: Loading images...");
  133. // Get queue status to find all completed jobs
  134. const queueStatus = await apiClient.getQueueStatus();
  135. console.log("Gallery: Queue status:", queueStatus);
  136. const completedJobs = queueStatus.jobs.filter(
  137. (job) => job.status === "completed",
  138. );
  139. console.log("Gallery: Completed jobs:", completedJobs);
  140. const galleryImages: GalleryImage[] = [];
  141. // Fetch individual job details to get output information
  142. for (const job of completedJobs) {
  143. const jobId = job.request_id || job.id || "";
  144. if (!jobId) continue;
  145. try {
  146. // Get detailed job information including outputs
  147. console.log(`Gallery: Fetching details for job ${jobId}`);
  148. const jobDetails: JobDetailsResponse =
  149. await apiClient.getJobStatus(jobId);
  150. console.log(`Gallery: Job ${jobId} details:`, jobDetails);
  151. // API response has outputs nested in job object
  152. if (
  153. jobDetails.job &&
  154. jobDetails.job.outputs &&
  155. jobDetails.job.outputs.length > 0
  156. ) {
  157. for (const output of jobDetails.job.outputs) {
  158. const filename = output.filename;
  159. const url = apiClient.getImageUrl(jobId, filename);
  160. // Create thumbnail URL (we'll use the same URL but let the browser handle scaling)
  161. const thumbnailUrl = url;
  162. galleryImages.push({
  163. jobId,
  164. filename,
  165. url,
  166. thumbnailUrl,
  167. prompt: jobDetails.job.prompt || "", // Get prompt from job details
  168. negativePrompt: "", // Job info doesn't include negative prompt
  169. width: undefined, // Job info doesn't include request details
  170. height: undefined, // Job info doesn't include request details
  171. steps: undefined, // Job info doesn't include request details
  172. cfgScale: undefined, // Job info doesn't include request details
  173. seed: undefined, // Job info doesn't include request details
  174. model: "Unknown", // Job info doesn't include request details
  175. createdAt:
  176. jobDetails.job.created_at || new Date().toISOString(),
  177. status: jobDetails.job.status,
  178. });
  179. }
  180. }
  181. } catch (err) {
  182. console.warn(`Failed to fetch details for job ${jobId}:`, err);
  183. // Continue with other jobs even if one fails
  184. }
  185. }
  186. // Sort images
  187. galleryImages.sort((a, b) => {
  188. const dateA = new Date(a.createdAt).getTime();
  189. const dateB = new Date(b.createdAt).getTime();
  190. return sortBy === "newest" ? dateB - dateA : dateA - dateB;
  191. });
  192. console.log("Gallery: Final images array:", galleryImages);
  193. setImages(galleryImages);
  194. } catch (err) {
  195. console.error("Gallery: Error loading images:", err);
  196. setError(
  197. err instanceof Error ? err.message : "Failed to load gallery images",
  198. );
  199. } finally {
  200. setLoading(false);
  201. }
  202. }, [sortBy]);
  203. useEffect(() => {
  204. loadGalleryImages();
  205. }, [sortBy, loadGalleryImages]);
  206. const handleImageClick = (image: GalleryImage) => {
  207. setSelectedImage(image);
  208. setIsModalOpen(true);
  209. };
  210. const handleModalClose = () => {
  211. setIsModalOpen(false);
  212. setSelectedImage(null);
  213. };
  214. const handleDownload = (image: GalleryImage, e: React.MouseEvent) => {
  215. e.stopPropagation();
  216. const authToken = localStorage.getItem("auth_token");
  217. const unixUser = localStorage.getItem("unix_user");
  218. downloadAuthenticatedImage(
  219. image.url,
  220. `gallery-${image.jobId}-${image.filename}`,
  221. authToken || undefined,
  222. unixUser || undefined,
  223. );
  224. };
  225. const formatDate = (dateString: string) => {
  226. return new Date(dateString).toLocaleDateString("en-US", {
  227. year: "numeric",
  228. month: "short",
  229. day: "numeric",
  230. hour: "2-digit",
  231. minute: "2-digit",
  232. });
  233. };
  234. if (loading) {
  235. return (
  236. <AppLayout>
  237. <Header title="Gallery" description="Browse your generated images" />
  238. <div className="container mx-auto p-6">
  239. <div className="flex items-center justify-center h-96">
  240. <div className="flex items-center gap-2">
  241. <Loader2 className="h-6 w-6 animate-spin" />
  242. <span>Loading gallery...</span>
  243. </div>
  244. </div>
  245. </div>
  246. </AppLayout>
  247. );
  248. }
  249. if (error) {
  250. return (
  251. <AppLayout>
  252. <Header title="Gallery" description="Browse your generated images" />
  253. <div className="container mx-auto p-6">
  254. <div className="flex flex-col items-center justify-center h-96 gap-4">
  255. <div className="text-destructive text-center">
  256. <p className="text-lg font-medium">Error loading gallery</p>
  257. <p className="text-sm">{error}</p>
  258. </div>
  259. <Button onClick={loadGalleryImages} variant="outline">
  260. <RefreshCw className="h-4 w-4 mr-2" />
  261. Try Again
  262. </Button>
  263. </div>
  264. </div>
  265. </AppLayout>
  266. );
  267. }
  268. return (
  269. <AppLayout>
  270. <Header title="Gallery" description="Browse your generated images" />
  271. <div className="container mx-auto p-6">
  272. {/* Controls */}
  273. <div className="flex items-center justify-between mb-6">
  274. <div className="flex items-center gap-4">
  275. <h2 className="text-2xl font-bold">
  276. {images.length} {images.length === 1 ? "Image" : "Images"}
  277. </h2>
  278. <Button onClick={loadGalleryImages} variant="outline" size="sm">
  279. <RefreshCw className="h-4 w-4 mr-2" />
  280. Refresh
  281. </Button>
  282. </div>
  283. <div className="flex items-center gap-2">
  284. <span className="text-sm text-muted-foreground">Sort by:</span>
  285. <Button
  286. variant={sortBy === "newest" ? "default" : "outline"}
  287. size="sm"
  288. onClick={() => setSortBy("newest")}
  289. >
  290. Newest
  291. </Button>
  292. <Button
  293. variant={sortBy === "oldest" ? "default" : "outline"}
  294. size="sm"
  295. onClick={() => setSortBy("oldest")}
  296. >
  297. Oldest
  298. </Button>
  299. </div>
  300. </div>
  301. {/* Gallery Grid */}
  302. {images.length === 0 ? (
  303. <div className="flex flex-col items-center justify-center h-96 border-2 border-dashed border-border rounded-lg">
  304. <ImageIcon className="h-12 w-12 text-muted-foreground mb-4" />
  305. <h3 className="text-lg font-medium text-muted-foreground mb-2">
  306. No images found
  307. </h3>
  308. <p className="text-sm text-muted-foreground text-center max-w-md">
  309. Generate some images first using the Text to Image, Image to
  310. Image, or Inpainting tools.
  311. </p>
  312. </div>
  313. ) : (
  314. <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
  315. {images.map((image, index) => (
  316. <Card
  317. key={`${image.jobId}-${image.filename}-${index}`}
  318. className="group cursor-pointer overflow-hidden hover:shadow-lg transition-all duration-200"
  319. onClick={() => handleImageClick(image)}
  320. >
  321. <CardContent className="p-0">
  322. <div className="relative aspect-square">
  323. <img
  324. src={image.thumbnailUrl}
  325. alt={`Generated image ${index + 1}`}
  326. className="w-full h-full object-cover"
  327. loading="lazy"
  328. />
  329. {/* Overlay with actions */}
  330. <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
  331. <Button
  332. size="icon"
  333. variant="secondary"
  334. className="h-8 w-8"
  335. onClick={(e) => {
  336. e.stopPropagation();
  337. handleImageClick(image);
  338. }}
  339. title="View full size"
  340. >
  341. <ZoomIn className="h-4 w-4" />
  342. </Button>
  343. <Button
  344. size="icon"
  345. variant="secondary"
  346. className="h-8 w-8"
  347. onClick={(e) => handleDownload(image, e)}
  348. title="Download image"
  349. >
  350. <Download className="h-4 w-4" />
  351. </Button>
  352. </div>
  353. {/* Date badge */}
  354. <div className="absolute top-2 left-2">
  355. <Badge variant="secondary" className="text-xs">
  356. {formatDate(image.createdAt)}
  357. </Badge>
  358. </div>
  359. {/* Model indicator */}
  360. {image.model && image.model !== "Unknown" && (
  361. <div className="absolute bottom-2 left-2">
  362. <Badge
  363. variant="outline"
  364. className="text-xs max-w-20 truncate"
  365. >
  366. {image.model}
  367. </Badge>
  368. </div>
  369. )}
  370. </div>
  371. {/* Image info */}
  372. <div className="p-3">
  373. <div className="flex items-center justify-between text-xs text-muted-foreground">
  374. <span className="truncate">
  375. {image.width && image.height
  376. ? `${image.width}×${image.height}`
  377. : "Unknown size"}
  378. </span>
  379. <div className="flex items-center gap-1">
  380. <Calendar className="h-3 w-3" />
  381. <span>
  382. {new Date(image.createdAt).toLocaleDateString()}
  383. </span>
  384. </div>
  385. </div>
  386. {image.prompt && (
  387. <p className="mt-1 text-xs text-muted-foreground line-clamp-2">
  388. {image.prompt}
  389. </p>
  390. )}
  391. </div>
  392. </CardContent>
  393. </Card>
  394. ))}
  395. </div>
  396. )}
  397. {/* Image Modal */}
  398. <ImageModal
  399. image={selectedImage}
  400. isOpen={isModalOpen}
  401. onClose={handleModalClose}
  402. />
  403. </div>
  404. </AppLayout>
  405. );
  406. }
  407. export default function GalleryPage() {
  408. return <GalleryGrid />;
  409. }