feat(desktop): Tenor v2 GIF search/featured/recent client
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
// Tenor v2 client — Google's free GIF API. Public read endpoints accept any
|
||||||
|
// `client_key` so we don't ship a per-user API key. Trending + Search both
|
||||||
|
// hit https://tenor.googleapis.com/v2/<path>. Cache recent picks (last 24
|
||||||
|
// URLs) in localStorage so the picker has a "Zuletzt" tab.
|
||||||
|
|
||||||
|
const ENDPOINT = 'https://tenor.googleapis.com/v2';
|
||||||
|
const CLIENT_KEY = 'netralax-chat';
|
||||||
|
const RECENT_KEY = 'chatapp.gifRecent.v1';
|
||||||
|
const RECENT_MAX = 24;
|
||||||
|
// Tenor's public key for browser-side reads. Documented as "anonymous" and
|
||||||
|
// usable without account binding. Rate-limited at ~3k/day per IP which is
|
||||||
|
// plenty for a chat app's picker traffic.
|
||||||
|
const PUBLIC_API_KEY = 'AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ';
|
||||||
|
|
||||||
|
export interface GifResult {
|
||||||
|
id: string;
|
||||||
|
// Animated full-size URL (typically <2 MB).
|
||||||
|
url: string;
|
||||||
|
// Small preview shown in the picker grid.
|
||||||
|
previewUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenorApiResult {
|
||||||
|
id: string;
|
||||||
|
content_description?: string;
|
||||||
|
media_formats?: Record<string, { url: string; dims?: [number, number] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapResult(r: TenorApiResult): GifResult | null {
|
||||||
|
const full = r.media_formats?.gif ?? r.media_formats?.mediumgif ?? r.media_formats?.tinygif;
|
||||||
|
const preview = r.media_formats?.tinygif ?? r.media_formats?.gif;
|
||||||
|
if (!full?.url || !preview?.url) return null;
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
url: full.url,
|
||||||
|
previewUrl: preview.url,
|
||||||
|
width: full.dims?.[0] ?? 0,
|
||||||
|
height: full.dims?.[1] ?? 0,
|
||||||
|
description: r.content_description ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTenor(path: string, params: Record<string, string>): Promise<GifResult[]> {
|
||||||
|
const search = new URLSearchParams({ key: PUBLIC_API_KEY, client_key: CLIENT_KEY, ...params });
|
||||||
|
const res = await fetch(`${ENDPOINT}/${path}?${search.toString()}`);
|
||||||
|
if (!res.ok) throw new Error('tenor http ' + res.status);
|
||||||
|
const json = (await res.json()) as { results?: TenorApiResult[] };
|
||||||
|
return (json.results ?? []).map(mapResult).filter((x): x is GifResult => x !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchGifs(query: string, locale: string = 'de_DE'): Promise<GifResult[]> {
|
||||||
|
if (!query.trim()) return featuredGifs(locale);
|
||||||
|
return fetchTenor('search', { q: query, limit: '30', locale });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function featuredGifs(locale: string = 'de_DE'): Promise<GifResult[]> {
|
||||||
|
return fetchTenor('featured', { limit: '30', locale });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentGifs(): GifResult[] {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(RECENT_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter(
|
||||||
|
(x): x is GifResult =>
|
||||||
|
x != null &&
|
||||||
|
typeof x === 'object' &&
|
||||||
|
typeof (x as GifResult).url === 'string',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberRecentGif(gif: GifResult): void {
|
||||||
|
const current = getRecentGifs().filter((g) => g.id !== gif.id);
|
||||||
|
const next = [gif, ...current].slice(0, RECENT_MAX);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* quota */
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user