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
@@ -10,6 +10,7 @@ import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
import { BansPanel } from './spaceSettingsPanels/BansPanel';
import { StickersPanel } from './spaceSettingsPanels/StickersPanel';
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
@@ -270,7 +271,7 @@ export function SpaceSettingsModal() {
const spaces = useSpaceStore((s) => s.spaces);
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview');
const [tab, setTab] = useState<'overview' | 'discovery' | 'stickers' | 'members' | 'roles' | 'bans'>('overview');
const isOpen = activeModal === 'spaceSettings';
const space = spaces.find(s => s.id === currentSpaceId);
@@ -300,6 +301,11 @@ export function SpaceSettingsModal() {
Discovery
</button>
)}
{canManageSpace && (
<button onClick={() => setTab('stickers')} className={tabClass('stickers')}>
Stickers
</button>
)}
<button onClick={() => setTab('members')} className={tabClass('members')}>
Members
</button>
@@ -320,6 +326,7 @@ export function SpaceSettingsModal() {
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
{tab === 'overview' && <OverviewPanel spaceId={currentSpaceId} />}
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
{tab === 'stickers' && canManageSpace && <StickersPanel spaceId={currentSpaceId} />}
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
@@ -11,21 +11,44 @@ export function GeneralPanel() {
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
const [gifKeyDirty, setGifKeyDirty] = useState(false);
const [gifKeyDraft, setGifKeyDraft] = useState('');
useEffect(() => {
if (instanceSettings) setDraft({ ...instanceSettings });
if (instanceSettings) {
setDraft({ ...instanceSettings });
// Don't populate the input with the masked value — show empty field
setGifKeyDraft('');
setGifKeyDirty(false);
}
}, [instanceSettings]);
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
const hasChanges = JSON.stringify(draft) !== JSON.stringify(instanceSettings);
const baseChanges = instanceSettings && draft
? draft.instanceName !== instanceSettings.instanceName ||
draft.registrationOpen !== instanceSettings.registrationOpen ||
draft.discoveryEnabled !== instanceSettings.discoveryEnabled
: false;
const hasChanges = baseChanges || gifKeyDirty;
const handleSave = async () => {
setSaving(true);
setSaveError('');
setSaveSuccess(false);
try {
await updateInstanceSettings(draft);
const payload: Partial<InstanceAdminSettings> = {
instanceName: draft!.instanceName,
registrationOpen: draft!.registrationOpen,
discoveryEnabled: draft!.discoveryEnabled,
};
// Only include gifApiKey when the user actually modified it
if (gifKeyDirty) {
payload.gifApiKey = gifKeyDraft;
}
await updateInstanceSettings(payload);
setGifKeyDirty(false);
setGifKeyDraft('');
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
@@ -37,6 +60,8 @@ export function GeneralPanel() {
const handleReset = () => {
if (instanceSettings) setDraft({ ...instanceSettings });
setGifKeyDirty(false);
setGifKeyDraft('');
setSaveError('');
};
@@ -90,6 +115,39 @@ export function GeneralPanel() {
</div>
</div>
{/* GIF Search */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">GIF Search</div>
<p className="text-xs text-txt-tertiary mb-2">
Enable GIF search powered by Klipy. Get a free API key from the Klipy developer portal.
</p>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-2">
<input
type="password"
value={gifKeyDirty ? gifKeyDraft : ''}
onChange={(e) => { setGifKeyDraft(e.target.value); setGifKeyDirty(true); }}
placeholder={draft.gifEnabled ? 'Key saved — enter new key to replace' : 'Klipy API key'}
className="input-standard w-full"
autoComplete="off"
/>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 text-[11px] font-medium px-1.5 py-0.5 rounded ${
draft.gifEnabled ? 'bg-status-online/15 text-status-online' : 'bg-white/5 text-txt-tertiary'
}`}>
{draft.gifEnabled ? 'Enabled' : 'Not configured'}
</span>
{draft.gifEnabled && !gifKeyDirty && (
<button
onClick={() => { setGifKeyDraft(''); setGifKeyDirty(true); }}
className="text-[11px] text-txt-tertiary hover:text-txt-danger transition-colors"
>
Clear key
</button>
)}
</div>
</div>
</div>
{/* Status messages */}
{saveError && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
@@ -0,0 +1,301 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../../../api/client';
import { ConfirmDialog } from '../../ui/ConfirmDialog';
import type { StickerPack, Sticker } from '@backspace/shared';
interface StickersPanelProps {
spaceId: string;
}
export function StickersPanel({ spaceId }: StickersPanelProps) {
const [packs, setPacks] = useState<StickerPack[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Create pack state
const [newPackName, setNewPackName] = useState('');
const [newPackDesc, setNewPackDesc] = useState('');
const [creating, setCreating] = useState(false);
// Upload sticker state
const [uploadPackId, setUploadPackId] = useState<string | null>(null);
const [stickerName, setStickerName] = useState('');
const [stickerTags, setStickerTags] = useState('');
const [stickerFile, setStickerFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Delete confirmation
const [deleteTarget, setDeleteTarget] = useState<{ type: 'pack' | 'sticker'; id: string; name: string } | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchPacks = async () => {
try {
const { packs: data } = await api.stickers.getPacks(spaceId);
setPacks(data);
setLoading(false);
} catch {
setError('Failed to load sticker packs');
setLoading(false);
}
};
useEffect(() => {
fetchPacks();
}, [spaceId]);
const handleCreatePack = async () => {
if (!newPackName.trim()) return;
setCreating(true);
setError('');
try {
const pack = await api.stickers.createPack(spaceId, {
name: newPackName.trim(),
description: newPackDesc.trim() || undefined,
});
setPacks((prev) => [...prev, { ...pack, stickers: [] }]);
setNewPackName('');
setNewPackDesc('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create pack');
} finally {
setCreating(false);
}
};
const handleUploadSticker = async () => {
if (!uploadPackId || !stickerFile || !stickerName.trim()) return;
setUploading(true);
setError('');
try {
const sticker = await api.stickers.uploadSticker(
spaceId,
uploadPackId,
stickerFile,
stickerName.trim(),
stickerTags.trim(),
);
setPacks((prev) =>
prev.map((p) =>
p.id === uploadPackId
? { ...p, stickers: [...p.stickers, sticker] }
: p,
),
);
setStickerName('');
setStickerTags('');
setStickerFile(null);
setUploadPackId(null);
if (fileInputRef.current) fileInputRef.current.value = '';
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to upload sticker');
} finally {
setUploading(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
setError('');
try {
if (deleteTarget.type === 'pack') {
await api.stickers.deletePack(spaceId, deleteTarget.id);
setPacks((prev) => prev.filter((p) => p.id !== deleteTarget.id));
} else {
await api.stickers.deleteSticker(deleteTarget.id);
setPacks((prev) =>
prev.map((p) => ({
...p,
stickers: p.stickers.filter((s) => s.id !== deleteTarget.id),
})),
);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
} finally {
setDeleting(false);
setDeleteTarget(null);
}
};
const getStickerUrl = (sticker: Sticker) => {
if (sticker.filename.startsWith('http') || sticker.filename.startsWith('/'))
return sticker.filename;
return `/api/uploads/${sticker.filename}`;
};
if (loading) {
return <div className="text-sm text-txt-tertiary">Loading sticker packs...</div>;
}
return (
<div className="space-y-5">
<div className="text-xs text-txt-tertiary">
Manage sticker packs for this space. Members can use these stickers in messages.
</div>
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
{error}
</div>
)}
{/* Create Pack */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Create Sticker Pack
</div>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-2">
<input
type="text"
value={newPackName}
onChange={(e) => setNewPackName(e.target.value.slice(0, 32))}
placeholder="Pack name"
className="input-standard w-full"
/>
<input
type="text"
value={newPackDesc}
onChange={(e) => setNewPackDesc(e.target.value.slice(0, 100))}
placeholder="Description (optional)"
className="input-standard w-full"
/>
<button
onClick={handleCreatePack}
disabled={creating || !newPackName.trim()}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Pack'}
</button>
</div>
</div>
{/* Existing Packs */}
{packs.length === 0 ? (
<div className="text-sm text-txt-tertiary">No sticker packs yet.</div>
) : (
<div className="space-y-4">
{packs.map((pack) => (
<div key={pack.id} className="rounded-lg bg-white/[0.02] p-3.5">
<div className="flex items-center justify-between mb-2">
<div>
<div className="text-sm font-medium text-txt-primary">{pack.name}</div>
{pack.description && (
<div className="text-xs text-txt-tertiary">{pack.description}</div>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setUploadPackId(uploadPackId === pack.id ? null : pack.id)}
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-interactive-hover hover:bg-interactive-active rounded transition-colors"
>
{uploadPackId === pack.id ? 'Cancel' : 'Add Sticker'}
</button>
<button
onClick={() => setDeleteTarget({ type: 'pack', id: pack.id, name: pack.name })}
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/20 rounded transition-colors"
>
Delete Pack
</button>
</div>
</div>
{/* Upload form for this pack */}
{uploadPackId === pack.id && (
<div className="border-t border-white/[0.06] pt-2 mt-2 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={stickerName}
onChange={(e) => setStickerName(e.target.value.slice(0, 32))}
placeholder="Sticker name"
className="input-standard flex-1"
/>
<input
type="text"
value={stickerTags}
onChange={(e) => setStickerTags(e.target.value.slice(0, 100))}
placeholder="Tags (optional)"
className="input-standard flex-1"
/>
</div>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/webp,image/gif"
onChange={(e) => setStickerFile(e.target.files?.[0] ?? null)}
className="text-sm text-txt-secondary file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-interactive-hover file:text-txt-primary hover:file:bg-interactive-active"
/>
<button
onClick={handleUploadSticker}
disabled={uploading || !stickerFile || !stickerName.trim()}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-xs font-medium rounded transition-colors disabled:opacity-50 flex-shrink-0"
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
<div className="text-[10px] text-txt-tertiary">
PNG, WebP, or GIF. Max 512x512px, 500KB.
</div>
</div>
)}
{/* Sticker grid */}
{pack.stickers.length > 0 && (
<div className="grid grid-cols-5 gap-2 mt-2">
{pack.stickers.map((sticker) => (
<div
key={sticker.id}
className="relative group aspect-square rounded-lg bg-surface-base overflow-hidden"
>
<img
src={getStickerUrl(sticker)}
alt={sticker.name}
className="w-full h-full object-contain p-1"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={() => setDeleteTarget({ type: 'sticker', id: sticker.id, name: sticker.name })}
className="p-1 text-white hover:text-txt-danger transition-colors"
title="Delete sticker"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 text-[9px] text-white truncate opacity-0 group-hover:opacity-100 transition-opacity">
{sticker.name}
</div>
</div>
))}
</div>
)}
{pack.stickers.length === 0 && (
<div className="text-xs text-txt-tertiary mt-1">No stickers in this pack yet.</div>
)}
</div>
))}
</div>
)}
{/* Delete confirmation */}
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title={`Delete ${deleteTarget?.type === 'pack' ? 'Sticker Pack' : 'Sticker'}`}
description={`Are you sure you want to delete "${deleteTarget?.name}"?${
deleteTarget?.type === 'pack' ? ' All stickers in this pack will be deleted.' : ''
} Existing messages will show "Sticker unavailable".`}
confirmLabel={deleting ? 'Deleting...' : 'Delete'}
onConfirm={handleDelete}
variant="danger"
loading={deleting}
/>
</div>
);
}