返回 presentation-ai
PresentationImageSearch.tsx
根目录 / src / components / ai / generative-ui / PresentationImageSearch.tsx
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
182 lines Plain Text