feat: GIF search (Klipy), stickers, emoji picker, and bug fixes
- Add GIF search powered by Klipy API with correct response mapping (file.sm/hd tiers, not flat files structure) - Add sticker system: packs, upload with auto-downscale, send in messages - Add tabbed InputPopover with emoji, GIF, and sticker pickers - Fix GIF API key migration race condition (column-add loop vs rename) - Fix masked API key corruption on settings save (server + client guards) - Fix sticker packs 403 (reversed isMember parameter order) - Fix emoji picker not filling popover width (perLine 8→9, CSS 100%) - Add error logging for Klipy API failures
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import type { GifResult } from '@backspace/shared';
|
||||
|
||||
interface KlipyGifFile {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface KlipySizeTier {
|
||||
gif?: KlipyGifFile;
|
||||
webp?: KlipyGifFile;
|
||||
mp4?: KlipyGifFile;
|
||||
jpg?: KlipyGifFile;
|
||||
webm?: KlipyGifFile;
|
||||
}
|
||||
|
||||
interface KlipyGif {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
file: {
|
||||
hd?: KlipySizeTier;
|
||||
md?: KlipySizeTier;
|
||||
sm?: KlipySizeTier;
|
||||
xs?: KlipySizeTier;
|
||||
};
|
||||
}
|
||||
|
||||
interface KlipyResponse {
|
||||
result: boolean;
|
||||
data: {
|
||||
data: KlipyGif[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
has_next: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function mapKlipyResults(gifs: KlipyGif[]): GifResult[] {
|
||||
return gifs
|
||||
.map((g) => {
|
||||
if (!g.file) return null;
|
||||
// Preview: small tier, prefer webp (smaller) then gif
|
||||
const smTier = g.file.sm ?? g.file.xs;
|
||||
const preview = smTier?.webp ?? smTier?.gif;
|
||||
// Full: HD tier, prefer gif (original quality) then webp
|
||||
const hdTier = g.file.hd ?? g.file.md;
|
||||
const full = hdTier?.gif ?? hdTier?.webp;
|
||||
if (!preview || !full) return null;
|
||||
return {
|
||||
id: g.slug || String(g.id),
|
||||
title: g.title ?? '',
|
||||
previewUrl: preview.url,
|
||||
url: full.url,
|
||||
width: preview.width,
|
||||
height: preview.height,
|
||||
};
|
||||
})
|
||||
.filter((r): r is GifResult => r !== null);
|
||||
}
|
||||
|
||||
function getGifApiKey(): string | null {
|
||||
const db = getDb();
|
||||
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
return row?.gifApiKey ?? null;
|
||||
}
|
||||
|
||||
export async function gifRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/gif/enabled — any authenticated user, returns whether GIF search is available
|
||||
app.get('/api/gif/enabled', { preHandler: authenticate }, async (_request, reply) => {
|
||||
const key = getGifApiKey();
|
||||
return reply.code(200).send({ enabled: !!key });
|
||||
});
|
||||
|
||||
// GET /api/gif/trending
|
||||
app.get<{ Querystring: { limit?: string; pos?: string } }>('/api/gif/trending', {
|
||||
preHandler: authenticate,
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 30,
|
||||
timeWindow: '1 minute',
|
||||
keyGenerator: (request: any) => request.userId || request.ip,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const key = getGifApiKey();
|
||||
if (!key) {
|
||||
return reply.code(200).send({ results: [], next: '' });
|
||||
}
|
||||
|
||||
const perPage = Math.min(Math.max(Number(request.query.limit) || 24, 1), 50);
|
||||
const page = Number(request.query.pos) || 1;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
per_page: String(perPage),
|
||||
page: String(page),
|
||||
});
|
||||
|
||||
const response = await fetch(`https://api.klipy.com/api/v1/${encodeURIComponent(key)}/gifs/trending?${params}`);
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
console.error(`[GIF] Klipy trending error: HTTP ${response.status} — ${body.slice(0, 200)}`);
|
||||
return reply.code(200).send({ results: [], next: '' });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as KlipyResponse;
|
||||
if (!data.result || !data.data) {
|
||||
console.error('[GIF] Klipy trending returned result=false or missing data');
|
||||
return reply.code(200).send({ results: [], next: '' });
|
||||
}
|
||||
|
||||
return reply.code(200).send({
|
||||
results: mapKlipyResults(data.data.data),
|
||||
next: data.data.has_next ? String(data.data.current_page + 1) : '',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[GIF] Klipy trending fetch failed:', err);
|
||||
return reply.code(200).send({ results: [], next: '' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/gif/search
|
||||
app.get<{ Querystring: { q?: string; limit?: string; pos?: string } }>('/api/gif/search', {
|
||||
preHandler: authenticate,
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 30,
|
||||
timeWindow: '1 minute',
|
||||
keyGenerator: (request: any) => request.userId || request.ip,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const key = getGifApiKey();
|
||||
if (!key) {
|
||||
return reply.code(200).send({ results: [], next: '' });
|
||||
}
|
||||
|
||||
const q = request.query.q?.trim();
|
||||
if (!q) {
|
||||
return reply.code(400).send({ error: 'Search query is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
const perPage = Math.min(Math.max(Number(request.query.limit) || 24, 1), 50);
|
||||
const page = Number(request.query.pos) || 1;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
per_page: String(perPage),
|
||||
page: String(page),
|
||||
});
|
||||
|
||||
const response = await fetch(`https://api.klipy.com/api/v1/${encodeURIComponent(key)}/gifs/search?${params}`);
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
console.error(`[GIF] Klipy search error: HTTP ${response.status} — ${body.slice(0, 200)}`);
|
||||
return reply.code(200).send({ results: [], next: '' });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as KlipyResponse;
|
||||
if (!data.result || !data.data) {
|
||||
console.error('[GIF] Klipy search returned result=false or missing data');
|
||||
return reply.code(200).send({ results: [], next: '' });
|
||||
}
|
||||
|
||||
return reply.code(200).send({
|
||||
results: mapKlipyResults(data.data.data),
|
||||
next: data.data.has_next ? String(data.data.current_page + 1) : '',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[GIF] Klipy search fetch failed:', err);
|
||||
return reply.code(200).send({ results: [], next: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user