feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
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.
This commit is contained in:
254
convex/gifs.ts
254
convex/gifs.ts
@@ -3,41 +3,265 @@
|
||||
import { action } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
// Search GIFs via Tenor API
|
||||
/**
|
||||
* 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) => {
|
||||
const apiKey = process.env.TENOR_API_KEY;
|
||||
|
||||
handler: async (_ctx, args): Promise<GifSearchResponse> => {
|
||||
const apiKey = process.env.KLIPY_API_KEY || process.env.TENOR_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.warn("TENOR_API_KEY missing");
|
||||
console.warn("KLIPY_API_KEY missing");
|
||||
return { results: [] };
|
||||
}
|
||||
|
||||
const limit = args.limit || 8;
|
||||
const url = `https://tenor.googleapis.com/v2/search?q=${encodeURIComponent(args.q)}&key=${apiKey}&limit=${limit}`;
|
||||
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: [] };
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
console.error("Tenor API Error:", response.statusText);
|
||||
console.error("Klipy API error:", response.status, response.statusText);
|
||||
return { results: [] };
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
// Get GIF categories
|
||||
/**
|
||||
* 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 () => {
|
||||
// Return static categories (same as the JSON file in backend)
|
||||
// These are loaded from the frontend data file
|
||||
return { categories: [] };
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user