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:
Jannis Braun
2026-03-15 02:04:37 +01:00
parent 7113f47b17
commit 3de6e4a668
26 changed files with 2160 additions and 50 deletions
+72
View File
@@ -46,6 +46,9 @@ import type {
SpaceLayoutItem,
SpaceFolder,
InvitePreview,
GifResult,
StickerPack,
Sticker,
} from '@backspace/shared';
export class RateLimitError extends Error {
@@ -187,6 +190,22 @@ export class BackspaceApiClient {
myJoinRequests: (status?: string) => Promise<{ requests: JoinRequest[] }>;
};
readonly gif: {
trending: (limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>;
search: (q: string, limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>;
enabled: () => Promise<{ enabled: boolean }>;
};
readonly stickers: {
getPacks: (spaceId: string) => Promise<{ packs: StickerPack[] }>;
createPack: (spaceId: string, data: { name: string; description?: string }) => Promise<StickerPack>;
updatePack: (spaceId: string, packId: string, data: { name?: string; description?: string }) => Promise<StickerPack>;
deletePack: (spaceId: string, packId: string) => Promise<{ success: boolean }>;
uploadSticker: (spaceId: string, packId: string, file: File, name: string, tags?: string) => Promise<Sticker>;
deleteSticker: (stickerId: string) => Promise<{ success: boolean }>;
myStickers: () => Promise<{ packs: StickerPack[] }>;
};
readonly admin: {
storageStats: () => Promise<StorageStats>;
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
@@ -504,6 +523,59 @@ export class BackspaceApiClient {
},
};
this.gif = {
trending: (limit = 30, pos?: string) => {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (pos) params.set('pos', pos);
return request<{ results: GifResult[]; next: string }>('GET', `/gif/trending?${params}`);
},
search: (q: string, limit = 30, pos?: string) => {
const params = new URLSearchParams();
params.set('q', q);
params.set('limit', String(limit));
if (pos) params.set('pos', pos);
return request<{ results: GifResult[]; next: string }>('GET', `/gif/search?${params}`);
},
enabled: () => request<{ enabled: boolean }>('GET', '/gif/enabled'),
};
this.stickers = {
getPacks: (spaceId: string) =>
request<{ packs: StickerPack[] }>('GET', `/spaces/${spaceId}/sticker-packs`),
createPack: (spaceId: string, data: { name: string; description?: string }) =>
request<StickerPack>('POST', `/spaces/${spaceId}/sticker-packs`, data),
updatePack: (spaceId: string, packId: string, data: { name?: string; description?: string }) =>
request<StickerPack>('PATCH', `/spaces/${spaceId}/sticker-packs/${packId}`, data),
deletePack: (spaceId: string, packId: string) =>
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/sticker-packs/${packId}`),
uploadSticker: async (spaceId: string, packId: string, file: File, name: string, tags = '') => {
const formData = new FormData();
formData.append('file', file);
formData.append('name', name);
formData.append('tags', tags);
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${baseUrl}/spaces/${spaceId}/sticker-packs/${packId}/stickers`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
throw new Error((error as { error: string }).error || `HTTP ${response.status}`);
}
return response.json() as Promise<Sticker>;
},
deleteSticker: (stickerId: string) =>
request<{ success: boolean }>('DELETE', `/stickers/${stickerId}`),
myStickers: () =>
request<{ packs: StickerPack[] }>('GET', '/users/@me/stickers'),
};
this.admin = {
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),