| 1 | "use client"; |
| 2 | |
| 3 | import { ChevronDown, Loader2 } from "lucide-react"; |
| 4 | import { useMemo, useState } from "react"; |
| 5 | |
| 6 | import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; |
| 7 | import { |
| 8 | type PresentationImageSearchResult, |
| 9 | type PresentationImageSearchResultItem, |
| 10 | } from "@/lib/presentation/image-search"; |
| 11 | import { cn } from "@/lib/utils"; |
| 12 | |
| 13 | function getImageCardKey(image: PresentationImageSearchResultItem): string { |
| 14 | return [image.url, image.sourceUrl ?? "", image.sourceTitle ?? ""].join("|"); |
| 15 | } |
| 16 | |
| 17 | function flattenImageResults( |
| 18 | searches: PresentationImageSearchResult[], |
| 19 | failedUrls: Set<string>, |
| 20 | ): PresentationImageSearchResultItem[] { |
| 21 | const seenUrls = new Set<string>(); |
| 22 | |
| 23 | return searches.flatMap((search) => |
| 24 | search.results.filter((image) => { |
| 25 | if (failedUrls.has(image.url) || seenUrls.has(image.url)) { |
| 26 | return false; |
| 27 | } |
| 28 | |
| 29 | seenUrls.add(image.url); |
| 30 | return true; |
| 31 | }), |
| 32 | ); |
| 33 | } |
| 34 | |
| 35 | export function PresentationImageSearchActivityCard({ |
| 36 | autoExpand = false, |
| 37 | searches, |
| 38 | pendingQueries = [], |
| 39 | }: { |
| 40 | autoExpand?: boolean; |
| 41 | searches: PresentationImageSearchResult[]; |
| 42 | pendingQueries?: string[]; |
| 43 | }) { |
| 44 | const [isExpanded, setIsExpanded] = useState(autoExpand); |
| 45 | const [failedUrls, setFailedUrls] = useState<string[]>([]); |
| 46 | const [selectedImage, setSelectedImage] = |
| 47 | useState<PresentationImageSearchResultItem | null>(null); |
| 48 | const failedUrlSet = useMemo(() => new Set(failedUrls), [failedUrls]); |
| 49 | |
| 50 | const allQueries = useMemo(() => { |
| 51 | const seen = new Set<string>(); |
| 52 | return [...searches.map((s) => s.query), ...pendingQueries].filter((q) => { |
| 53 | if (seen.has(q)) return false; |
| 54 | seen.add(q); |
| 55 | return true; |
| 56 | }); |
| 57 | }, [searches, pendingQueries]); |
| 58 | |
| 59 | const images = useMemo( |
| 60 | () => flattenImageResults(searches, failedUrlSet), |
| 61 | [searches, failedUrlSet], |
| 62 | ); |
| 63 | |
| 64 | const hasPendingSearches = pendingQueries.length > 0; |
| 65 | const hasContent = |
| 66 | allQueries.length > 0 || images.length > 0 || hasPendingSearches; |
| 67 | |
| 68 | if (!hasContent) { |
| 69 | return null; |
| 70 | } |
| 71 | |
| 72 | const blockLabel = |
| 73 | hasPendingSearches && images.length === 0 |
| 74 | ? "Searching images..." |
| 75 | : "Image Result"; |
| 76 | |
| 77 | return ( |
| 78 | <> |
| 79 | <div> |
| 80 | <button |
| 81 | type="button" |
| 82 | onClick={() => setIsExpanded((prev) => !prev)} |
| 83 | className="inline-flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden text-left text-sm text-muted-foreground/80 transition-colors hover:text-muted-foreground" |
| 84 | > |
| 85 | <span className="truncate whitespace-nowrap font-medium"> |
| 86 | {blockLabel} |
| 87 | </span> |
| 88 | <ChevronDown |
| 89 | className={cn( |
| 90 | "h-3.5 w-3.5 shrink-0 transition-transform duration-200", |
| 91 | isExpanded && "rotate-180", |
| 92 | )} |
| 93 | /> |
| 94 | </button> |
| 95 | |
| 96 | {isExpanded ? ( |
| 97 | <div className="relative mt-2 ml-1 pl-4 before:absolute before:top-0 before:left-0 before:h-full before:w-px before:bg-border/50"> |
| 98 | <div className="space-y-2"> |
| 99 | {allQueries.length > 0 ? ( |
| 100 | <div className="flex flex-wrap gap-1"> |
| 101 | {allQueries.map((query) => ( |
| 102 | <span |
| 103 | key={query} |
| 104 | className="inline-flex items-center gap-1 rounded-full border border-border/50 bg-muted/30 px-2 py-0.5 text-[11px] text-muted-foreground" |
| 105 | > |
| 106 | {pendingQueries.includes(query) ? ( |
| 107 | <Loader2 className="h-2.5 w-2.5 animate-spin" /> |
| 108 | ) : null} |
| 109 | <span className="max-w-48 truncate">{query}</span> |
| 110 | </span> |
| 111 | ))} |
| 112 | </div> |
| 113 | ) : null} |
| 114 | |
| 115 | {images.length > 0 ? ( |
| 116 | <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-4"> |
| 117 | {images.map((image) => ( |
| 118 | <button |
| 119 | type="button" |
| 120 | key={getImageCardKey(image)} |
| 121 | className="overflow-hidden rounded-md bg-muted/30" |
| 122 | onClick={() => setSelectedImage(image)} |
| 123 | > |
| 124 | {/* biome-ignore lint/performance/noImgElement: external image search results need plain img tags */} |
| 125 | <img |
| 126 | src={image.url} |
| 127 | alt={image.description} |
| 128 | className="aspect-4/3 w-full object-cover transition-opacity hover:opacity-90" |
| 129 | loading="lazy" |
| 130 | onError={() => |
| 131 | setFailedUrls((cur) => |
| 132 | cur.includes(image.url) ? cur : [...cur, image.url], |
| 133 | ) |
| 134 | } |
| 135 | /> |
| 136 | </button> |
| 137 | ))} |
| 138 | </div> |
| 139 | ) : hasPendingSearches ? ( |
| 140 | <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-4"> |
| 141 | {Array.from({ length: 8 }).map((_, i) => ( |
| 142 | <div |
| 143 | key={i} |
| 144 | className="aspect-4/3 w-full animate-pulse rounded-md bg-muted/40" |
| 145 | /> |
| 146 | ))} |
| 147 | </div> |
| 148 | ) : null} |
| 149 | </div> |
| 150 | </div> |
| 151 | ) : null} |
| 152 | </div> |
| 153 | |
| 154 | <Dialog |
| 155 | open={selectedImage !== null} |
| 156 | onOpenChange={(open) => { |
| 157 | if (!open) { |
| 158 | setSelectedImage(null); |
| 159 | } |
| 160 | }} |
| 161 | > |
| 162 | <DialogContent |
| 163 | className="max-w-[min(96vw,72rem)] border-border/60 bg-background/95 p-2" |
| 164 | shouldHaveClose={false} |
| 165 | > |
| 166 | <DialogTitle className="sr-only">Expanded image preview</DialogTitle> |
| 167 | {selectedImage ? ( |
| 168 | <div className="overflow-hidden rounded-md"> |
| 169 | {/* biome-ignore lint/performance/noImgElement: external image search results need plain img tags */} |
| 170 | <img |
| 171 | src={selectedImage.url} |
| 172 | alt={selectedImage.description} |
| 173 | className="max-h-[85vh] w-full object-contain" |
| 174 | /> |
| 175 | </div> |
| 176 | ) : null} |
| 177 | </DialogContent> |
| 178 | </Dialog> |
| 179 | </> |
| 180 | ); |
| 181 | } |
| 182 |