feat(desktop): GIF picker popover (Tenor + trending/search/recent)

This commit is contained in:
byGalax
2026-05-16 18:05:45 +02:00
parent f1d9602a76
commit 7726498218
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState } from 'react';
import {
featuredGifs,
getRecentGifs,
type GifResult,
rememberRecentGif,
searchGifs,
} from '../lib/tenor';
import { SpinnerIcon, XIcon } from './icons';
interface Props {
open: boolean;
onClose: () => void;
onPick: (gif: GifResult) => void;
}
type Tab = 'trending' | 'search' | 'recent';
export function GifPicker({ open, onClose, onPick }: Props) {
const [tab, setTab] = useState<Tab>('trending');
const [query, setQuery] = useState('');
const [results, setResults] = useState<GifResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const recent = useMemo(() => getRecentGifs(), [open]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError(null);
const run = async () => {
try {
const gifs = tab === 'search' && query.trim().length > 0
? await searchGifs(query)
: tab === 'trending'
? await featuredGifs()
: [];
if (!cancelled) setResults(gifs);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'GIFs gerade nicht verfügbar');
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => { cancelled = true; };
}, [open, tab, query]);
if (!open) return null;
const visible = tab === 'recent' ? recent : results;
return (
<div className="absolute bottom-full left-0 z-30 mb-2 w-[440px] rounded-2xl border border-line bg-surface-2 shadow-xl">
<header className="flex items-center justify-between border-b border-line px-3 py-2">
<div className="flex items-center gap-1">
{(['trending', 'search', 'recent'] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={
'cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition ' +
(t === tab ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:text-fg')
}
>
{t === 'trending' ? 'Trending' : t === 'search' ? 'Suche' : 'Zuletzt'}
</button>
))}
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</header>
{tab === 'search' && (
<div className="border-b border-line px-3 py-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="GIFs suchen…"
className="w-full rounded-md border border-line bg-surface-3 px-2 py-1.5 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
</div>
)}
<div className="grid max-h-[360px] grid-cols-3 gap-1.5 overflow-y-auto p-2">
{loading && (
<div className="col-span-3 flex justify-center py-6 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
)}
{error && (
<p className="col-span-3 px-2 py-4 text-center text-xs text-rose-300">
{error === 'tenor_api_key_missing'
? 'GIFs sind nicht konfiguriert (VITE_TENOR_API_KEY fehlt).'
: 'GIFs gerade nicht verfügbar.'}
</p>
)}
{!loading && !error && visible.length === 0 && (
<p className="col-span-3 px-2 py-6 text-center text-xs text-fg-muted">
{tab === 'recent' ? 'Noch keine zuletzt verwendeten GIFs.' : 'Keine Treffer.'}
</p>
)}
{!loading && !error && visible.map((g) => (
<button
key={g.id}
type="button"
onClick={() => { rememberRecentGif(g); onPick(g); onClose(); }}
className="overflow-hidden rounded-md border border-line bg-surface-3 hover:border-accent/40"
title={g.description}
>
<img src={g.previewUrl} alt={g.description || 'GIF'} className="h-24 w-full object-cover" loading="lazy" />
</button>
))}
</div>
</div>
);
}