// ── AI Assistant: GIF Picker Component ── // Inline GIF search and selection UI backed by GIPHY. // Supports trending, search with debounce, infinite scroll, and client-side caching. "use client" import { useState, useRef, useEffect, useCallback } from "react" import { Search, Loader2, ImageIcon } from "lucide-react" /** A single GIF result from the GIPHY API */ interface GifResult { id: string title: string url: string previewUrl: string previewHeight: number width: number height: number } interface GifPickerProps { /** Called when a GIF is selected */ onSelect: (gif: { url: string; previewUrl: string; title: string }) => void /** Called to close the picker */ onClose: () => void } /** In-memory cache keyed by search query or "__trending__" */ const SEARCH_CACHE = new Map() /** * GifPicker — inline GIF search/selection popover. * Fetches trending GIFs on open, supports debounced search and infinite scroll via IntersectionObserver. */ export function GifPicker({ onSelect, onClose }: GifPickerProps) { const [gifs, setGifs] = useState([]) const [loading, setLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const [search, setSearch] = useState("") const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(true) const [error, setError] = useState("") const sentinelRef = useRef(null) const inputRef = useRef(null) const searchTimeoutRef = useRef>() // ── Fetch GIFs from the API, with optional cache hit ── const fetchGifs = useCallback(async (q: string, off: number, append: boolean) => { if (off === 0) { const cacheKey = q || "__trending__" const cached = SEARCH_CACHE.get(cacheKey) if (cached) { setGifs(cached) setLoading(false) setHasMore(true) setOffset(cached.length) return } } // ── Perform the API request ── try { if (!append) setLoading(true) else setLoadingMore(true) setError("") const params = new URLSearchParams() if (q) { params.set("type", "search") params.set("q", q) } params.set("offset", String(off)) params.set("limit", "20") const res = await fetch(`/api/ai/giphy?${params}`) if (!res.ok) { const data = await res.json() throw new Error(data.error || "Failed to fetch GIFs") } const data = await res.json() const newGifs: GifResult[] = data.gifs || [] // Append or replace results, caching on first page if (append) { setGifs((prev) => [...prev, ...newGifs]) } else { setGifs(newGifs) if (off === 0 && !q) { SEARCH_CACHE.set("__trending__", newGifs) } if (q) { SEARCH_CACHE.set(q, newGifs) } } setOffset(off + newGifs.length) setHasMore(newGifs.length === 20) } catch (err: any) { setError(err.message || "Failed to load GIFs") } finally { setLoading(false) setLoadingMore(false) } }, []) // ── Load trending GIFs on mount ── useEffect(() => { fetchGifs("", 0, false) }, [fetchGifs]) // ── Auto-focus the search input ── useEffect(() => { inputRef.current?.focus() }, []) // ── Debounced search: 400ms after the user stops typing ── useEffect(() => { if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) if (!search.trim()) { fetchGifs("", 0, false) return } searchTimeoutRef.current = setTimeout(() => { fetchGifs(search.trim(), 0, false) }, 400) return () => { if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) } }, [search, fetchGifs]) // ── Infinite scroll via IntersectionObserver on the sentinel ── useEffect(() => { const sentinel = sentinelRef.current if (!sentinel) return const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) { fetchGifs(search.trim(), offset, true) } }, { rootMargin: "200px" } ) observer.observe(sentinel) return () => observer.disconnect() }, [hasMore, loadingMore, loading, offset, search, fetchGifs]) return ( // ── Popover container ──
{/* ── Search bar ── */}
setSearch(e.target.value)} placeholder="Search GIFs..." className="w-full bg-muted/50 text-foreground text-sm rounded-xl pl-9 pr-4 py-2.5 outline-none focus:ring-2 focus:ring-primary/30 placeholder:text-muted-foreground/60" />
{/* ── GIF grid with loading / error / empty states ── */}
{loading ? (
) : error ? (

{error}

) : gifs.length === 0 ? (

No GIFs found.

) : ( <>
{gifs.map((gif) => ( ))}
{loadingMore && (
)} {/* Sentinel element for infinite scroll detection */} {hasMore &&
} )}
) }