All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- 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.
268 lines
8.1 KiB
TypeScript
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;
|
|
},
|
|
});
|