voice note and sticker pack and gifs
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
"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 [gifNoKey, setGifNoKey] = useState(false)
|
||||
const [gifNext, setGifNext] = useState("")
|
||||
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 = "") => {
|
||||
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) {
|
||||
setGifNoKey(true)
|
||||
setGifResults([])
|
||||
} else if (data.results) {
|
||||
setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results))
|
||||
setGifNext(data.next || "")
|
||||
setGifNoKey(false)
|
||||
} else {
|
||||
setGifError("No results found")
|
||||
}
|
||||
} catch {
|
||||
setGifError("Failed to load GIFs")
|
||||
}
|
||||
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>
|
||||
) : gifNoKey ? (
|
||||
<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>GIF search requires a Tenor API key</span>
|
||||
<span className="text-xs opacity-60">Set TENOR_API_KEY in .env.local</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user