303 lines
13 KiB
TypeScript
303 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react"
|
|
import { cn } from "@/lib/utils"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Search, Image, Sticker, X, Loader2, TrendingUp } from "lucide-react"
|
|
import data from "@emoji-mart/data"
|
|
import Picker from "@emoji-mart/react"
|
|
import { getStickerPacks, type StickerPack } from "@/data/stickers"
|
|
|
|
type Tab = "emoji" | "gif" | "sticker"
|
|
|
|
interface MediaPickerProps {
|
|
onEmojiSelect: (emoji: string) => void
|
|
onMediaSelect: (content: string) => void
|
|
onClose: () => void
|
|
theme?: string
|
|
}
|
|
|
|
const RECENT_GIFS_KEY = "recent-gifs"
|
|
const RECENT_STICKERS_KEY = "recent-stickers"
|
|
const MAX_RECENT = 12
|
|
|
|
function getRecent(key: string): string[] {
|
|
if (typeof window === "undefined") return []
|
|
try { return JSON.parse(localStorage.getItem(key) || "[]") } catch { return [] }
|
|
}
|
|
|
|
function addRecent(key: string, id: string) {
|
|
if (typeof window === "undefined") return
|
|
const items = [id, ...getRecent(key).filter((x) => x !== id)].slice(0, MAX_RECENT)
|
|
localStorage.setItem(key, JSON.stringify(items))
|
|
}
|
|
|
|
export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, theme }: MediaPickerProps) {
|
|
const [tab, setTab] = useState<Tab>("emoji")
|
|
const [gifQuery, setGifQuery] = useState("")
|
|
const [gifResults, setGifResults] = useState<any[]>([])
|
|
const [gifLoading, setGifLoading] = useState(false)
|
|
const [gifError, setGifError] = useState("")
|
|
const [gifUnavailable, setGifUnavailable] = useState(false)
|
|
const [gifNext, setGifNext] = useState("")
|
|
const gifCache = useRef<Map<string, { results: any[]; next: string }>>(new Map())
|
|
const [recentGifs, setRecentGifs] = useState<string[]>([])
|
|
const [recentStickers, setRecentStickers] = useState<string[]>([])
|
|
const [stickerPacks] = useState<StickerPack[]>(getStickerPacks)
|
|
const [activePack, setActivePack] = useState(stickerPacks[0]?.id || "")
|
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const pickerRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) onClose()
|
|
}
|
|
document.addEventListener("mousedown", handleClick)
|
|
return () => document.removeEventListener("mousedown", handleClick)
|
|
}, [onClose])
|
|
|
|
useEffect(() => { setRecentGifs(getRecent(RECENT_GIFS_KEY)) }, [])
|
|
useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, [])
|
|
|
|
const fetchGifs = useCallback(async (query: string, pos = "") => {
|
|
const cacheKey = `${query}::${pos}`
|
|
const cached = gifCache.current.get(cacheKey)
|
|
if (cached && !pos) {
|
|
setGifResults(cached.results)
|
|
setGifNext(cached.next)
|
|
return
|
|
}
|
|
setGifLoading(true)
|
|
setGifError("")
|
|
try {
|
|
const params = new URLSearchParams({ limit: "20", pos })
|
|
if (query) params.set("q", query)
|
|
const res = await fetch(`/api/gifs?${params}`)
|
|
const data = await res.json()
|
|
if (data.noKey) {
|
|
setGifUnavailable(true)
|
|
setGifResults([])
|
|
} else if (data.results) {
|
|
setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results))
|
|
setGifNext(data.next || "")
|
|
setGifUnavailable(false)
|
|
if (!pos) gifCache.current.set(cacheKey, { results: data.results, next: data.next || "" })
|
|
} else {
|
|
setGifError("No results found")
|
|
}
|
|
} catch {
|
|
setGifError("Could not load GIFs. Check your connection.")
|
|
}
|
|
setGifLoading(false)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (tab !== "gif") return
|
|
if (searchTimer.current) clearTimeout(searchTimer.current)
|
|
if (gifQuery.trim()) {
|
|
searchTimer.current = setTimeout(() => fetchGifs(gifQuery.trim()), 400)
|
|
} else {
|
|
fetchGifs("")
|
|
}
|
|
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
|
}, [tab, gifQuery, fetchGifs])
|
|
|
|
const handleGifSelect = (gif: any) => {
|
|
const content = JSON.stringify({ gif: gif.url, w: gif.width, h: gif.height })
|
|
addRecent(RECENT_GIFS_KEY, gif.id)
|
|
setRecentGifs(getRecent(RECENT_GIFS_KEY))
|
|
onMediaSelect(content)
|
|
}
|
|
|
|
const handleStickerSelect = (sticker: any) => {
|
|
const content = sticker.svg
|
|
? JSON.stringify({ sticker: sticker.svg, w: 200, h: 200, id: sticker.id })
|
|
: JSON.stringify({ sticker: sticker.emoji, w: 120, h: 120, id: sticker.id })
|
|
addRecent(RECENT_STICKERS_KEY, sticker.id)
|
|
setRecentStickers(getRecent(RECENT_STICKERS_KEY))
|
|
onMediaSelect(content)
|
|
}
|
|
|
|
const activeStickers = stickerPacks.find((p) => p.id === activePack)?.stickers || []
|
|
|
|
const renderGifGrid = (items: any[], emptyMsg: string) => {
|
|
if (items.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
|
<Image className="h-8 w-8 opacity-40" />
|
|
<span>{emptyMsg}</span>
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
{items.map((gif: any) => (
|
|
<button
|
|
key={gif.id}
|
|
type="button"
|
|
onClick={() => handleGifSelect(gif)}
|
|
className="rounded-lg overflow-hidden bg-muted/50 hover:ring-2 hover:ring-primary/50 transition-all aspect-video"
|
|
>
|
|
<img src={gif.previewUrl || gif.url} alt={gif.title || "GIF"} className="w-full h-full object-cover" loading="lazy" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div ref={pickerRef} className="w-[360px] rounded-xl border bg-popover shadow-xl overflow-hidden">
|
|
{/* Tabs */}
|
|
<div className="flex border-b">
|
|
<button type="button" onClick={() => setTab("emoji")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "emoji" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
|
|
Emoji
|
|
{tab === "emoji" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
|
|
</button>
|
|
<button type="button" onClick={() => setTab("gif")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "gif" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
|
|
GIFs
|
|
{tab === "gif" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
|
|
</button>
|
|
<button type="button" onClick={() => setTab("sticker")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "sticker" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
|
|
Stickers
|
|
{tab === "sticker" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Emoji Tab */}
|
|
{tab === "emoji" && (
|
|
<div className="max-h-[380px] overflow-hidden">
|
|
<Picker
|
|
data={data}
|
|
onEmojiSelect={(emoji: any) => { onEmojiSelect(emoji.native); onClose() }}
|
|
theme={theme === "dark" ? "dark" : "light"}
|
|
previewPosition="none"
|
|
skinTonePosition="none"
|
|
set="native"
|
|
emojiSize={32}
|
|
maxFrequentRows={2}
|
|
perLine={8}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* GIF Tab */}
|
|
{tab === "gif" && (
|
|
<div className="flex flex-col h-[380px]">
|
|
<div className="p-2 border-b">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={gifQuery}
|
|
onChange={(e) => setGifQuery(e.target.value)}
|
|
placeholder="Search GIFs..."
|
|
className="h-8 pl-8 text-sm"
|
|
/>
|
|
{gifQuery && (
|
|
<button type="button" onClick={() => setGifQuery("")} className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-2">
|
|
{gifLoading && gifResults.length === 0 ? (
|
|
<div className="flex items-center justify-center h-48">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : gifUnavailable ? (
|
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
|
<Image className="h-8 w-8 opacity-40" />
|
|
<span>GIFs are temporarily unavailable.</span>
|
|
</div>
|
|
) : gifError ? (
|
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
|
<TrendingUp className="h-8 w-8 opacity-40" />
|
|
<span>{gifError}</span>
|
|
<button type="button" onClick={() => fetchGifs(gifQuery)} className="text-xs text-primary hover:underline">Retry</button>
|
|
</div>
|
|
) : gifQuery.trim() ? (
|
|
renderGifGrid(gifResults, "No GIFs found")
|
|
) : recentGifs.length > 0 && gifResults.length === 0 ? (
|
|
<>
|
|
<div className="text-xs font-medium text-muted-foreground px-1 pb-2">Recent GIFs</div>
|
|
{renderGifGrid(
|
|
recentGifs.map((id) => gifResults.find((g: any) => g.id === id)).filter(Boolean),
|
|
"No recent GIFs"
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="text-xs font-medium text-muted-foreground px-1 pb-2">Trending</div>
|
|
{renderGifGrid(gifResults, "No trending GIFs available")}
|
|
</>
|
|
)}
|
|
{gifNext && !gifLoading && (
|
|
<div className="flex justify-center pt-2 pb-1">
|
|
<Button type="button" variant="ghost" size="sm" className="text-xs h-7" onClick={() => fetchGifs(gifQuery, gifNext)}>
|
|
Load more
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stickers Tab */}
|
|
{tab === "sticker" && (
|
|
<div className="flex flex-col h-[380px]">
|
|
<div className="flex gap-1 p-2 border-b overflow-x-auto shrink-0">
|
|
{stickerPacks.map((pack) => (
|
|
<button
|
|
key={pack.id}
|
|
type="button"
|
|
onClick={() => setActivePack(pack.id)}
|
|
className={cn(
|
|
"px-3 py-1.5 text-xs rounded-md whitespace-nowrap transition-colors",
|
|
activePack === pack.id ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground",
|
|
)}
|
|
>
|
|
{pack.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-2">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{activeStickers.map((sticker) => (
|
|
<button
|
|
key={sticker.id}
|
|
type="button"
|
|
onClick={() => handleStickerSelect(sticker)}
|
|
className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
|
|
>
|
|
{sticker.svg ? (
|
|
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sticker.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
|
|
) : (
|
|
<span className="text-5xl">{sticker.emoji}</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{recentStickers.length > 0 && (
|
|
<>
|
|
<div className="text-xs font-medium text-muted-foreground pt-4 pb-2">Recently Used</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{recentStickers.map((id) => {
|
|
const s = stickerPacks.flatMap((p) => p.stickers).find((x) => x.id === id)
|
|
if (!s) return null
|
|
return (
|
|
<button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100">
|
|
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: s.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|