Files
DiscordClone/convex/gifs.ts
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

268 lines
8.1 KiB
TypeScript

"use node";
import { action } from "./_generated/server";
import { v } from "convex/values";
/**
* GIF search action — backed by the Klipy GIF API (Tenor's
* Google-shutdown replacement). Klipy embeds the customer id in the
* URL path and returns results under `data.data[]` with sized
* variants under `file.{hd|md|sm}.gif.url`. We normalize the response
* into a flat `{results: [{id, title, url, previewUrl, width, height}]}`
* shape so callers don't have to know which provider is upstream.
*
* Reads `KLIPY_API_KEY` from the Convex environment. Falls back to
* the legacy `TENOR_API_KEY` env var so existing deployments keep
* working as soon as the old key is replaced with a Klipy customer
* id — no `convex env set` rename required.
*/
interface NormalizedGif {
id: string;
title: string;
url: string;
previewUrl: string;
width?: number;
height?: number;
}
interface GifSearchResponse {
results: NormalizedGif[];
}
/**
* In-memory TTL cache for Klipy responses. Convex Node actions run on
* warm container instances, so this survives between invocations on
* the same worker until it's recycled. Best-effort only — a cold
* start will re-fetch upstream. Search queries live for 5 minutes,
* trending / categories for 30 minutes since they change slowly.
*/
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
const CACHE = new Map<string, CacheEntry<unknown>>();
const MAX_CACHE_SIZE = 200;
const SEARCH_TTL_MS = 5 * 60 * 1000;
const TRENDING_TTL_MS = 30 * 60 * 1000;
const CATEGORIES_TTL_MS = 30 * 60 * 1000;
function cacheGet<T>(key: string): T | null {
const entry = CACHE.get(key);
if (!entry) return null;
if (entry.expiresAt < Date.now()) {
CACHE.delete(key);
return null;
}
return entry.value as T;
}
function cacheSet<T>(key: string, value: T, ttlMs: number): void {
if (CACHE.size >= MAX_CACHE_SIZE) {
// Drop the oldest insertion — Map iteration order is insertion order.
const oldest = CACHE.keys().next().value;
if (oldest !== undefined) CACHE.delete(oldest);
}
CACHE.set(key, { value, expiresAt: Date.now() + ttlMs });
}
function normalizeGifItems(items: any[]): NormalizedGif[] {
return items
.map((item, idx): NormalizedGif => {
const file = item?.file ?? {};
const md = file?.md?.gif ?? file?.sm?.gif ?? file?.hd?.gif ?? {};
const previewVariant =
file?.sm?.gif ?? file?.md?.gif ?? file?.hd?.gif ?? {};
const fullUrl: string = md?.url ?? item?.url ?? "";
const previewUrl: string = previewVariant?.url ?? fullUrl;
const width: number | undefined =
typeof md?.width === "number" ? md.width : undefined;
const height: number | undefined =
typeof md?.height === "number" ? md.height : undefined;
return {
id: String(item?.slug ?? item?.id ?? `${idx}`),
title: String(item?.title ?? ""),
url: fullUrl,
previewUrl,
width,
height,
};
})
.filter((r) => !!r.url);
}
export const search = action({
args: {
q: v.string(),
limit: v.optional(v.number()),
},
returns: v.any(),
handler: async (_ctx, args): Promise<GifSearchResponse> => {
const apiKey = process.env.KLIPY_API_KEY || process.env.TENOR_API_KEY;
if (!apiKey) {
console.warn("KLIPY_API_KEY missing");
return { results: [] };
}
const limit = Math.min(Math.max(args.limit ?? 24, 1), 50);
const query = args.q.trim().toLowerCase();
const cacheKey = `search:${query}:${limit}`;
const cached = cacheGet<GifSearchResponse>(cacheKey);
if (cached) return cached;
// Klipy customer id goes in the path; per-page caps the result
// set without a separate `limit` query param.
const url = `https://api.klipy.com/api/v1/${encodeURIComponent(apiKey)}/gifs/search?q=${encodeURIComponent(args.q)}&per_page=${limit}&page=1&locale=en`;
let response: Response;
try {
response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent":
"Brycord/1.0 (+https://brycord.com) Klipy-Client",
},
});
} catch (err) {
console.error("Klipy fetch error:", err);
return { results: [] };
}
if (!response.ok) {
console.error("Klipy API error:", response.status, response.statusText);
return { results: [] };
}
const json = (await response.json()) as any;
const items: any[] = Array.isArray(json?.data?.data) ? json.data.data : [];
const results = normalizeGifItems(items);
const payload: GifSearchResponse = { results };
cacheSet(cacheKey, payload, SEARCH_TTL_MS);
return payload;
},
});
/**
* Trending GIFs — used for the picker's home feed when no query is
* typed. Returns the same normalized shape as `search` so callers
* can use a single render path for both.
*/
export const trending = action({
args: {
limit: v.optional(v.number()),
},
returns: v.any(),
handler: async (_ctx, args): Promise<GifSearchResponse> => {
const apiKey = process.env.KLIPY_API_KEY || process.env.TENOR_API_KEY;
if (!apiKey) return { results: [] };
const limit = Math.min(Math.max(args.limit ?? 24, 1), 50);
const cacheKey = `trending:${limit}`;
const cached = cacheGet<GifSearchResponse>(cacheKey);
if (cached) return cached;
const url = `https://api.klipy.com/api/v1/${encodeURIComponent(apiKey)}/gifs/trending?per_page=${limit}&page=1&locale=en`;
let response: Response;
try {
response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": "Brycord/1.0 (+https://brycord.com) Klipy-Client",
},
});
} catch (err) {
console.error("Klipy trending fetch error:", err);
return { results: [] };
}
if (!response.ok) {
console.error(
"Klipy trending API error:",
response.status,
response.statusText,
);
return { results: [] };
}
const json = (await response.json()) as any;
const items: any[] = Array.isArray(json?.data?.data) ? json.data.data : [];
const results = normalizeGifItems(items);
const payload: GifSearchResponse = { results };
cacheSet(cacheKey, payload, TRENDING_TTL_MS);
return payload;
},
});
/**
* Trending categories — Klipy exposes `/categories` returning a list
* of slugs the picker can show as quick-search chips. Normalized
* into `{categories: [{name, image, query}]}` so the consumer
* doesn't depend on the upstream shape.
*/
interface NormalizedCategory {
name: string;
image: string;
query: string;
}
export const categories = action({
args: {},
returns: v.any(),
handler: async (): Promise<{ categories: NormalizedCategory[] }> => {
const apiKey = process.env.KLIPY_API_KEY || process.env.TENOR_API_KEY;
if (!apiKey) {
return { categories: [] };
}
const cacheKey = `categories`;
const cached = cacheGet<{ categories: NormalizedCategory[] }>(cacheKey);
if (cached) return cached;
const url = `https://api.klipy.com/api/v1/${encodeURIComponent(apiKey)}/gifs/categories?locale=en`;
let response: Response;
try {
response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent":
"Brycord/1.0 (+https://brycord.com) Klipy-Client",
},
});
} catch (err) {
console.error("Klipy categories fetch error:", err);
return { categories: [] };
}
if (!response.ok) {
console.error(
"Klipy categories API error:",
response.status,
response.statusText,
);
return { categories: [] };
}
const json = (await response.json()) as any;
const items: any[] = Array.isArray(json?.data?.data)
? json.data.data
: Array.isArray(json?.data)
? json.data
: [];
const categories: NormalizedCategory[] = items
.map((item) => ({
name: String(item?.name ?? item?.title ?? ""),
image: String(item?.image ?? item?.preview ?? ""),
query: String(item?.query ?? item?.search_term ?? item?.name ?? ""),
}))
.filter((c) => !!c.query);
const payload = { categories };
cacheSet(cacheKey, payload, CATEGORIES_TTL_MS);
return payload;
},
});