返回 presentation-ai
SharedImageSearchControls.tsx
根目录 / src / components / presentation / shared / SharedImageSearchControls.tsx
1 "use client";
2
3 import { useQuery } from "@tanstack/react-query";
4 import { Search, TrendingUp } from "lucide-react";
5 import Image from "next/image";
6 import React, { useEffect } from "react";
7
8 import { searchGoogleImages } from "@/app/_actions/apps/image-studio/google";
9 import {
10 getTrendingPixabayImages,
11 searchPixabayImages,
12 } from "@/app/_actions/apps/image-studio/pixabay";
13 import {
14 getTrendingUnsplashImages,
15 searchUnsplashImages,
16 triggerUnsplashDownload,
17 } from "@/app/_actions/apps/image-studio/unsplash";
18 import { Button } from "@/components/ui/button";
19 import { Input } from "@/components/ui/input";
20 import { ScrollArea } from "@/components/ui/scroll-area";
21 import { Skeleton } from "@/components/ui/skeleton";
22 import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
23 import { cn } from "@/lib/utils";
24 import {
25 usePresentationState,
26 type PresentationStockImageProvider,
27 } from "@/states/presentation-state";
28
29 interface SharedImageSearchControlsProps {
30 onImageSelect: (
31 url: string,
32 provider: PresentationStockImageProvider,
33 ) => void;
34 className?: string;
35 initialQuery?: string;
36 initialQueryKey?: string;
37 disableTrendingFallback?: boolean;
38 }
39
40 type SearchResultImage = {
41 url: string;
42 thumb?: string;
43 title?: string;
44 author?: string;
45 username?: string;
46 downloadLocation?: string;
47 link?: string;
48 source?: string;
49 };
50
51 const PROVIDER_LABELS: Record<PresentationStockImageProvider, string> = {
52 unsplash: "Unsplash",
53 pixabay: "Pixabay",
54 google: "Web Search",
55 };
56
57 export function SharedImageSearchControls({
58 onImageSelect,
59 className,
60 initialQuery = "",
61 initialQueryKey,
62 disableTrendingFallback = false,
63 }: SharedImageSearchControlsProps) {
64 const imageSearchState = usePresentationState((s) => s.imageSearchState);
65 const setImageSearchState = usePresentationState(
66 (s) => s.setImageSearchState,
67 );
68
69 const {
70 mode = "unsplash",
71 unsplashQuery = "",
72 pixabayQuery = "",
73 googleQuery = "",
74 } = imageSearchState;
75
76 const [selectedUrl, setSelectedUrl] = React.useState<string>("");
77
78 // Seed all image providers with the page-specific query when the panel opens.
79 useEffect(() => {
80 const trimmedInitialQuery = initialQuery.trim();
81 if (!trimmedInitialQuery) return;
82
83 setImageSearchState({
84 unsplashQuery: trimmedInitialQuery,
85 pixabayQuery: trimmedInitialQuery,
86 googleQuery: trimmedInitialQuery,
87 });
88 }, [initialQuery, initialQueryKey, setImageSearchState]);
89
90 const unsplashQ = useQuery({
91 queryKey: ["presentation-image", "unsplash", unsplashQuery],
92 queryFn: async () => {
93 const trimmedQuery = unsplashQuery.trim();
94 if (!trimmedQuery) {
95 const res = disableTrendingFallback
96 ? null
97 : await getTrendingUnsplashImages(30, 1);
98 return res?.success && res.images
99 ? res.images.map((i) => ({
100 url: i.url,
101 thumb: i.thumb,
102 author: i.author,
103 username: i.username,
104 downloadLocation: i.downloadLocation,
105 link: i.link,
106 }))
107 : [];
108 }
109
110 const res = await searchUnsplashImages(trimmedQuery, 30, 1);
111 return res.success && res.images
112 ? res.images.map((i) => ({
113 url: i.url,
114 thumb: i.thumb,
115 author: i.author,
116 username: i.username,
117 downloadLocation: i.downloadLocation,
118 link: i.link,
119 }))
120 : [];
121 },
122 enabled: mode === "unsplash",
123 staleTime: Infinity,
124 refetchOnWindowFocus: false,
125 });
126
127 const pixabayQ = useQuery({
128 queryKey: ["presentation-image", "pixabay", pixabayQuery],
129 queryFn: async () => {
130 const trimmedQuery = pixabayQuery.trim();
131 if (!trimmedQuery) {
132 const res = disableTrendingFallback
133 ? null
134 : await getTrendingPixabayImages();
135 return res?.success && res.images
136 ? res.images.map((i) => ({
137 url: i.url,
138 thumb: i.thumb,
139 title: i.title,
140 author: i.author,
141 link: i.link,
142 }))
143 : [];
144 }
145
146 const res = await searchPixabayImages(trimmedQuery);
147 return res.success && res.images
148 ? res.images.map((i) => ({
149 url: i.url,
150 thumb: i.thumb,
151 title: i.title,
152 author: i.author,
153 link: i.link,
154 }))
155 : [];
156 },
157 enabled: mode === "pixabay",
158 staleTime: Infinity,
159 refetchOnWindowFocus: false,
160 });
161
162 const googleQ = useQuery({
163 queryKey: ["presentation-image", "google", googleQuery],
164 queryFn: async () => {
165 if (!googleQuery.trim()) return [] as SearchResultImage[];
166 const res = await searchGoogleImages(googleQuery);
167 return res.success && res.images
168 ? res.images.map((i) => ({
169 url: i.url,
170 thumb: i.thumb,
171 title: i.title,
172 source: i.source,
173 }))
174 : [];
175 },
176 enabled: !!googleQuery && mode === "google",
177 staleTime: Infinity,
178 refetchOnWindowFocus: false,
179 });
180
181 const activeQuery =
182 mode === "unsplash"
183 ? unsplashQuery
184 : mode === "pixabay"
185 ? pixabayQuery
186 : googleQuery;
187
188 const activeResults =
189 mode === "unsplash"
190 ? unsplashQ.data
191 : mode === "pixabay"
192 ? pixabayQ.data
193 : googleQ.data;
194
195 const isFetching =
196 unsplashQ.isFetching || pixabayQ.isFetching || googleQ.isFetching;
197
198 const handleSearch = () => {
199 if (mode === "unsplash") void unsplashQ.refetch();
200 else if (mode === "pixabay") void pixabayQ.refetch();
201 else void googleQ.refetch();
202 };
203
204 const handleShowTrending = () => {
205 if (mode === "unsplash") {
206 setImageSearchState({ unsplashQuery: "" });
207 return;
208 }
209 if (mode === "pixabay") {
210 setImageSearchState({ pixabayQuery: "" });
211 }
212 };
213
214 const handleKeyDown = (e: React.KeyboardEvent) => {
215 if (e.key === "Enter") {
216 handleSearch();
217 }
218 };
219
220 return (
221 <div className={cn("flex h-full flex-col gap-4", className)}>
222 <Tabs
223 value={mode}
224 onValueChange={(v) =>
225 setImageSearchState({ mode: v as PresentationStockImageProvider })
226 }
227 className="w-full"
228 >
229 <TabsList className="grid w-full grid-cols-3">
230 <TabsTrigger value="unsplash">
231 Unsplash
232 </TabsTrigger>
233 <TabsTrigger value="pixabay">
234 Pixabay
235 </TabsTrigger>
236 <TabsTrigger value="google">
237 Web
238 </TabsTrigger>
239 </TabsList>
240 </Tabs>
241
242 <div className="flex gap-2">
243 <div className="relative flex-1">
244 <Search className="absolute top-2.5 left-2.5 size-4 text-muted-foreground" />
245 <Input
246 placeholder={
247 mode === "unsplash"
248 ? "Search high-res photos..."
249 : mode === "pixabay"
250 ? "Search Pixabay Images..."
251 : "Search live web images..."
252 }
253 value={activeQuery}
254 onChange={(e) =>
255 mode === "unsplash"
256 ? setImageSearchState({ unsplashQuery: e.target.value })
257 : mode === "pixabay"
258 ? setImageSearchState({ pixabayQuery: e.target.value })
259 : setImageSearchState({ googleQuery: e.target.value })
260 }
261 onKeyDown={handleKeyDown}
262 className="pl-9"
263 />
264 </div>
265 <Button onClick={handleSearch}>
266 Search
267 </Button>
268 {!disableTrendingFallback &&
269 (mode === "unsplash" || mode === "pixabay") &&
270 activeQuery && (
271 <Button
272 variant="outline"
273 size="icon"
274 onClick={handleShowTrending}
275 title={`Show popular ${PROVIDER_LABELS[mode]} images`}
276 >
277 <TrendingUp className="size-4" />
278 </Button>
279 )}
280 </div>
281
282 {!disableTrendingFallback &&
283 (mode === "unsplash" || mode === "pixabay") && (
284 <div className="flex items-center gap-2 text-xs text-muted-foreground">
285 {activeQuery.trim() ? (
286 <span>
287 Results for &quot;
288 <span className="font-medium">{activeQuery.trim()}</span>&quot;
289 </span>
290 ) : (
291 <span className="flex items-center gap-1">
292 <TrendingUp className="size-3" />
293 Popular {PROVIDER_LABELS[mode]} images
294 </span>
295 )}
296 </div>
297 )}
298
299 <ScrollArea className="flex-1 rounded-md border bg-muted/30 p-2">
300 <div className="min-h-full">
301 {isFetching && (
302 <div className="grid grid-cols-3 gap-2">
303 {Array.from({ length: 12 }).map((_, i) => (
304 <Skeleton key={i} className="aspect-square w-full rounded-md" />
305 ))}
306 </div>
307 )}
308
309 {!isFetching && (
310 <>
311 {mode === "unsplash" &&
312 Array.isArray(unsplashQ.data) &&
313 unsplashQ.data.length > 0 && (
314 <div className="grid h-max grid-cols-3 gap-2">
315 {unsplashQ.data.map(
316 (r: {
317 url: string;
318 thumb?: string;
319 author?: string;
320 username?: string;
321 downloadLocation?: string;
322 link?: string;
323 }) => (
324 <div key={r.url} className="group relative">
325 <button
326 type="button"
327 onClick={() => {
328 setSelectedUrl(r.url);
329 onImageSelect(r.url, "unsplash");
330 if (r.downloadLocation) {
331 void triggerUnsplashDownload(
332 r.downloadLocation,
333 );
334 }
335 }}
336 className={cn(
337 "aspect-square w-full overflow-hidden rounded-md border transition-all hover:scale-[1.02] focus:ring-2 focus:ring-primary focus:ring-offset-1 focus:outline-none",
338 selectedUrl === r.url
339 ? "border-primary ring-2 ring-primary ring-offset-1"
340 : "border-transparent hover:border-primary/50",
341 )}
342 >
343 <Image
344 unoptimized
345 width={400}
346 height={300}
347 src={r.thumb || r.url}
348 alt="unsplash"
349 className="size-full object-cover transition-opacity group-hover:opacity-90"
350 loading="lazy"
351 />
352 {selectedUrl === r.url && (
353 <div className="absolute inset-0 rounded-md ring-2 ring-primary ring-inset" />
354 )}
355 </button>
356 {/* Attribution Overlay */}
357 <div className="pointer-events-none absolute right-0 bottom-0 left-0 bg-black/60 p-1 text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100">
358 <span className="pointer-events-auto">
359 Photo by{" "}
360 <a
361 href={`https://unsplash.com/@${r.username}?utm_source=your_app_name&utm_medium=referral`}
362 target="_blank"
363 rel="noopener noreferrer"
364 className="underline hover:text-gray-200"
365 onClick={(e) => e.stopPropagation()}
366 >
367 {r.author}
368 </a>{" "}
369 on{" "}
370 <a
371 href={`${r.link || "https://unsplash.com"}?utm_source=your_app_name&utm_medium=referral`}
372 target="_blank"
373 rel="noopener noreferrer"
374 className="underline hover:text-gray-200"
375 onClick={(e) => e.stopPropagation()}
376 >
377 Unsplash
378 </a>
379 </span>
380 </div>
381 </div>
382 ),
383 )}
384 </div>
385 )}
386
387 {mode === "pixabay" &&
388 Array.isArray(pixabayQ.data) &&
389 pixabayQ.data.length > 0 && (
390 <div className="grid grid-cols-3 gap-2">
391 {pixabayQ.data.map(
392 (r: {
393 url: string;
394 thumb?: string;
395 title?: string;
396 author?: string;
397 link?: string;
398 }) => (
399 <div key={r.url} className="group relative">
400 <button
401 type="button"
402 onClick={() => {
403 setSelectedUrl(r.url);
404 onImageSelect(r.url, "pixabay");
405 }}
406 className={cn(
407 "aspect-square w-full overflow-hidden rounded-md border transition-all hover:scale-[1.02] focus:ring-2 focus:ring-primary focus:ring-offset-1 focus:outline-none",
408 selectedUrl === r.url
409 ? "border-primary ring-2 ring-primary ring-offset-1"
410 : "border-transparent hover:border-primary/50",
411 )}
412 title={r.title}
413 >
414 <Image
415 unoptimized
416 width={400}
417 height={300}
418 src={r.thumb || r.url}
419 alt={r.title || "pixabay image"}
420 className="size-full object-cover transition-opacity group-hover:opacity-90"
421 loading="lazy"
422 />
423 {selectedUrl === r.url && (
424 <div className="absolute inset-0 rounded-md ring-2 ring-primary ring-inset" />
425 )}
426 </button>
427 {/* Attribution Overlay */}
428 {r.author && (
429 <div className="pointer-events-none absolute right-0 bottom-0 left-0 bg-black/60 p-1 text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100">
430 <span className="pointer-events-auto">
431 Photo by {r.author} on{" "}
432 <a
433 href={r.link || "https://pixabay.com"}
434 target="_blank"
435 rel="noopener noreferrer"
436 className="underline hover:text-gray-200"
437 onClick={(e) => e.stopPropagation()}
438 >
439 Pixabay
440 </a>
441 </span>
442 </div>
443 )}
444 </div>
445 ),
446 )}
447 </div>
448 )}
449
450 {mode === "google" &&
451 Array.isArray(googleQ.data) &&
452 googleQ.data.length > 0 && (
453 <div className="grid grid-cols-3 gap-2">
454 {googleQ.data.map((r) => (
455 <div key={r.url} className="group relative">
456 <button
457 type="button"
458 onClick={() => {
459 setSelectedUrl(r.url);
460 onImageSelect(r.url, "google");
461 }}
462 className={cn(
463 "aspect-square w-full overflow-hidden rounded-md border transition-all hover:scale-[1.02] focus:ring-2 focus:ring-primary focus:ring-offset-1 focus:outline-none",
464 selectedUrl === r.url
465 ? "border-primary ring-2 ring-primary ring-offset-1"
466 : "border-transparent hover:border-primary/50",
467 )}
468 title={r.title}
469 >
470 <Image
471 unoptimized
472 width={400}
473 height={300}
474 src={r.thumb || r.url}
475 alt={r.title || "web search image"}
476 className="size-full object-cover transition-opacity group-hover:opacity-90"
477 loading="lazy"
478 />
479 {selectedUrl === r.url && (
480 <div className="absolute inset-0 rounded-md ring-2 ring-primary ring-inset" />
481 )}
482 </button>
483 {(r.title || r.source) && (
484 <div className="pointer-events-none absolute right-0 bottom-0 left-0 bg-black/60 p-1 text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100">
485 <span className="line-clamp-2">
486 {r.title || "Web image"}
487 {r.source ? ` - ${r.source}` : ""}
488 </span>
489 </div>
490 )}
491 </div>
492 ))}
493 </div>
494 )}
495
496 {/* Empty states */}
497 {(!activeResults || activeResults.length === 0) &&
498 !isFetching && (
499 <div className="flex h-40 flex-col items-center justify-center text-muted-foreground">
500 <p className="text-sm">
501 No {PROVIDER_LABELS[mode]} images found
502 </p>
503 </div>
504 )}
505 </>
506 )}
507 </div>
508 </ScrollArea>
509 </div>
510 );
511 }
512
512 lines Plain Text