返回 presentation-ai
SharedGifSearchControls.tsx
根目录 / src / components / presentation / shared / SharedGifSearchControls.tsx
1 "use client";
2
3 import { useQuery } from "@tanstack/react-query";
4 import { Loader2, Search, TrendingUp } from "lucide-react";
5 import Image from "next/image";
6 import type React from "react";
7 import { useEffect, useState } from "react";
8
9 import {
10 getTrendingGiphyGifs,
11 searchGiphyGifs,
12 } from "@/app/_actions/apps/image-studio/giphy";
13 import { Button } from "@/components/ui/button";
14 import { Input } from "@/components/ui/input";
15 import { ScrollArea } from "@/components/ui/scroll-area";
16 import { Skeleton } from "@/components/ui/skeleton";
17 import { cn } from "@/lib/utils";
18
19 interface SharedGifSearchControlsProps {
20 onGifSelect: (url: string) => void | Promise<void>;
21 className?: string;
22 }
23
24 export function SharedGifSearchControls({
25 onGifSelect,
26 className,
27 }: SharedGifSearchControlsProps) {
28 const [query, setQuery] = useState("");
29 const [searchQuery, setSearchQuery] = useState("");
30 const [selectedUrl, setSelectedUrl] = useState<string>("");
31 const [loadingGifUrl, setLoadingGifUrl] = useState<string | null>(null);
32
33 // Fetch trending GIFs on mount
34 const trendingQuery = useQuery({
35 queryKey: ["giphy-trending"],
36 queryFn: async () => {
37 const res = await getTrendingGiphyGifs();
38 return res.success && res.gifs ? res.gifs : [];
39 },
40 staleTime: Infinity,
41 refetchOnWindowFocus: false,
42 });
43
44 // Search GIFs
45 const searchQueryResult = useQuery({
46 queryKey: ["giphy-search", searchQuery],
47 queryFn: async () => {
48 if (!searchQuery.trim()) return [];
49 const res = await searchGiphyGifs(searchQuery);
50 return res.success && res.gifs ? res.gifs : [];
51 },
52 enabled: !!searchQuery,
53 staleTime: Infinity,
54 refetchOnWindowFocus: false,
55 });
56
57 // Load trending on mount
58 useEffect(() => {
59 if (!searchQuery) {
60 void trendingQuery.refetch();
61 }
62 }, []);
63
64 const handleSearch = () => {
65 if (query.trim()) {
66 setSearchQuery(query.trim());
67 }
68 };
69
70 const handleKeyDown = (e: React.KeyboardEvent) => {
71 if (e.key === "Enter") {
72 handleSearch();
73 }
74 };
75
76 const handleShowTrending = () => {
77 setQuery("");
78 setSearchQuery("");
79 };
80
81 const isLoading = searchQuery
82 ? searchQueryResult.isFetching
83 : trendingQuery.isFetching;
84 const gifs = searchQuery
85 ? (searchQueryResult.data ?? [])
86 : (trendingQuery.data ?? []);
87
88 return (
89 <div className={cn("flex h-full flex-col gap-4", className)}>
90 <div className="flex gap-2">
91 <div className="relative flex-1">
92 <Search className="absolute top-2.5 left-2.5 size-4 text-muted-foreground" />
93 <Input
94 placeholder="Search GIFs on Giphy..."
95 value={query}
96 onChange={(e) => setQuery(e.target.value)}
97 onKeyDown={handleKeyDown}
98 className="pl-9"
99 />
100 </div>
101 <Button onClick={handleSearch}>
102 Search
103 </Button>
104 {searchQuery && (
105 <Button
106 variant="outline"
107 size="icon"
108 onClick={handleShowTrending}
109 title="Show Trending"
110 >
111 <TrendingUp className="size-4" />
112 </Button>
113 )}
114 </div>
115
116 {/* Header showing current mode */}
117 <div className="flex items-center gap-2 text-xs text-muted-foreground">
118 {searchQuery ? (
119 <span>
120 Results for &quot;<span className="font-medium">{searchQuery}</span>
121 &quot;
122 </span>
123 ) : (
124 <span className="flex items-center gap-1">
125 <TrendingUp className="size-3" />
126 Trending GIFs
127 </span>
128 )}
129 </div>
130
131 <ScrollArea className="flex-1 rounded-md border bg-muted/30 p-2">
132 <div className="min-h-full">
133 {isLoading && (
134 <div className="grid grid-cols-3 gap-2">
135 {Array.from({ length: 12 }).map((_, i) => (
136 <Skeleton key={i} className="aspect-square w-full rounded-md" />
137 ))}
138 </div>
139 )}
140
141 {!isLoading && gifs.length > 0 && (
142 <div className="grid h-max grid-cols-3 gap-2">
143 {gifs.map((gif) => (
144 <div key={gif.id} className="group relative">
145 <button
146 type="button"
147 onClick={async () => {
148 if (loadingGifUrl) return; // Prevent multiple clicks
149 setLoadingGifUrl(gif.url);
150 setSelectedUrl(gif.url);
151 try {
152 await onGifSelect(gif.url);
153 } catch (caughtError) {
154 setLoadingGifUrl(null);
155 throw caughtError;
156 }
157 setLoadingGifUrl(null);
158 }}
159 disabled={loadingGifUrl !== null}
160 className={cn(
161 "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",
162 selectedUrl === gif.url
163 ? "border-primary ring-2 ring-primary ring-offset-1"
164 : "border-transparent hover:border-primary/50",
165 loadingGifUrl !== null &&
166 loadingGifUrl !== gif.url &&
167 "cursor-not-allowed opacity-50",
168 )}
169 >
170 <Image
171 unoptimized
172 width={400}
173 height={300}
174 src={gif.thumb || gif.url}
175 alt={gif.title || "GIF"}
176 className={cn(
177 "size-full object-cover transition-opacity group-hover:opacity-90",
178 loadingGifUrl === gif.url && "opacity-50",
179 )}
180 loading="lazy"
181 />
182 {/* Loading spinner overlay */}
183 {loadingGifUrl === gif.url && (
184 <div className="absolute inset-0 flex items-center justify-center rounded-md bg-black/30">
185 <Loader2 className="size-6 animate-spin text-white" />
186 </div>
187 )}
188 {selectedUrl === gif.url && loadingGifUrl !== gif.url && (
189 <div className="absolute inset-0 rounded-md ring-2 ring-primary ring-inset" />
190 )}
191 </button>
192 {/* Attribution Overlay */}
193 <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">
194 <span className="pointer-events-auto">
195 Powered by{" "}
196 <a
197 href="https://giphy.com"
198 target="_blank"
199 rel="noopener noreferrer"
200 className="underline hover:text-gray-200"
201 onClick={(e) => e.stopPropagation()}
202 >
203 GIPHY
204 </a>
205 </span>
206 </div>
207 </div>
208 ))}
209 </div>
210 )}
211
212 {/* Empty state */}
213 {!isLoading && gifs.length === 0 && (
214 <div className="flex h-40 flex-col items-center justify-center text-muted-foreground">
215 <p className="text-sm">
216 No GIFs found
217 </p>
218 </div>
219 )}
220 </div>
221 </ScrollArea>
222 </div>
223 );
224 }
225
225 lines Plain Text