feat(gif): favourites and category shortcuts
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Favourites are stored server-side per user, so one made on the phone is there on the desktop — the point of favouriting. The whole result is stored rather than an id: the provider offers no lookup by id, so an id-only favourite could not be rendered without re-finding it through search. Category chips translate their label but not their query, which goes to a provider that indexes in English. The star sits beside the tile button rather than inside it: a button within a button is invalid and swallows the click. Toggling is optimistic and reverts on failure, and favourites skip both the loading skeleton and the infinite scroll, which belong to provider-backed browsing only. Server caps favourites per user and rejects non-http(s) URLs, which become <img src> in everyone's picker.
This commit is contained in:
@@ -1,6 +1,21 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { GifResult } from '@backspace/shared';
|
||||
import { useT, type TranslationKey } from '../../i18n';
|
||||
|
||||
/**
|
||||
* Category shortcuts. The label is translated but the query is not: it is sent
|
||||
* to the provider, which indexes in English — a translated query would return
|
||||
* nothing.
|
||||
*/
|
||||
const CATEGORIES: { key: TranslationKey; query: string }[] = [
|
||||
{ key: 'gif.category.hello', query: 'hello' },
|
||||
{ key: 'gif.category.lol', query: 'lol' },
|
||||
{ key: 'gif.category.love', query: 'love' },
|
||||
{ key: 'gif.category.birthday', query: 'happy birthday' },
|
||||
{ key: 'gif.category.dance', query: 'dance' },
|
||||
{ key: 'gif.category.facepalm', query: 'facepalm' },
|
||||
];
|
||||
|
||||
interface GifPickerProps {
|
||||
onGifSelect: (url: string) => void;
|
||||
@@ -12,6 +27,10 @@ interface GifPickerProps {
|
||||
}
|
||||
|
||||
export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
||||
const t = useT();
|
||||
const [showFavorites, setShowFavorites] = useState(false);
|
||||
const [favorites, setFavorites] = useState<GifResult[]>([]);
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [results, setResults] = useState<GifResult[]>([]);
|
||||
@@ -21,6 +40,46 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Favourites load once and are kept in memory: the picker is opened and
|
||||
// closed constantly, and re-fetching on every open would be visible.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.gif.favorites()
|
||||
.then(({ results }) => {
|
||||
if (cancelled) return;
|
||||
setFavorites(results);
|
||||
setFavoriteIds(new Set(results.map((g) => g.id)));
|
||||
})
|
||||
.catch(() => { /* favourites are an enhancement; browsing still works */ });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const toggleFavorite = async (gif: GifResult, e: React.MouseEvent) => {
|
||||
// The tile behind this button inserts the GIF into the message.
|
||||
e.stopPropagation();
|
||||
const isFavorite = favoriteIds.has(gif.id);
|
||||
|
||||
// Optimistic: the star must feel instant. Reverted below if the call fails.
|
||||
setFavoriteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isFavorite) next.delete(gif.id); else next.add(gif.id);
|
||||
return next;
|
||||
});
|
||||
setFavorites((prev) => (isFavorite ? prev.filter((g) => g.id !== gif.id) : [gif, ...prev]));
|
||||
|
||||
try {
|
||||
if (isFavorite) await api.gif.removeFavorite(gif.id);
|
||||
else await api.gif.addFavorite(gif);
|
||||
} catch {
|
||||
setFavoriteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isFavorite) next.add(gif.id); else next.delete(gif.id);
|
||||
return next;
|
||||
});
|
||||
setFavorites((prev) => (isFavorite ? [gif, ...prev] : prev.filter((g) => g.id !== gif.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce search query
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
@@ -60,6 +119,8 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
||||
// Infinite scroll
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
// Favourites are a complete local list — nothing to page through.
|
||||
if (showFavorites) return;
|
||||
if (!el || loadingMore || !nextPos) return;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
|
||||
setLoadingMore(true);
|
||||
@@ -76,13 +137,16 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
||||
};
|
||||
fetchMore();
|
||||
}
|
||||
}, [loadingMore, nextPos, debouncedQuery]);
|
||||
}, [loadingMore, nextPos, debouncedQuery, showFavorites]);
|
||||
|
||||
// Prevent keyboard events from bubbling
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Favourites are a local list; browsing results come from the provider.
|
||||
const shown = showFavorites ? favorites : results;
|
||||
|
||||
// Mobile: fill parent (sheet sets width + max-height). Desktop: fixed dims
|
||||
// matching the legacy popover footprint.
|
||||
const rootClass = mobile
|
||||
@@ -97,7 +161,7 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search GIFs"
|
||||
placeholder={t('gif.search')}
|
||||
className="input-search w-full"
|
||||
// Auto-focus only on desktop. On mobile this would force the OS
|
||||
// keyboard up the moment the sheet opens, hiding most of the grid.
|
||||
@@ -105,13 +169,41 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category shortcuts */}
|
||||
<div className="flex gap-1.5 px-3 pb-2 overflow-x-auto no-scrollbar shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFavorites((v) => !v)}
|
||||
className={`px-2.5 py-1 rounded-full text-[12px] font-medium whitespace-nowrap transition-colors flex items-center gap-1 ${
|
||||
showFavorites
|
||||
? 'bg-accent-primary text-white'
|
||||
: 'bg-surface-elevated text-txt-secondary hover:text-txt-primary'
|
||||
}`}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="m12 17.27 6.18 3.73-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
{t('gif.tab.favorites')}
|
||||
</button>
|
||||
{CATEGORIES.map((category) => (
|
||||
<button
|
||||
key={category.query}
|
||||
type="button"
|
||||
onClick={() => { setShowFavorites(false); setQuery(category.query); }}
|
||||
className="px-2.5 py-1 rounded-full text-[12px] font-medium whitespace-nowrap bg-surface-elevated text-txt-secondary hover:text-txt-primary transition-colors"
|
||||
>
|
||||
{t(category.key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results grid */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-1"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{loading ? (
|
||||
{loading && !showFavorites ? (
|
||||
<div className="grid grid-cols-2 gap-1.5 p-1">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
@@ -121,29 +213,52 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm">
|
||||
{debouncedQuery.trim() ? 'No GIFs found' : 'No trending GIFs'}
|
||||
) : shown.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm text-center px-4">
|
||||
{showFavorites
|
||||
? t('gif.empty.favorites')
|
||||
: debouncedQuery.trim()
|
||||
? t('gif.empty.search')
|
||||
: t('gif.empty.trending')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="columns-2 gap-1.5 p-1">
|
||||
{results.map((gif) => (
|
||||
<button
|
||||
key={gif.id}
|
||||
onClick={() => onGifSelect(gif.url)}
|
||||
className="w-full mb-1.5 rounded-lg overflow-hidden hover:ring-2 hover:ring-accent-primary transition-all break-inside-avoid"
|
||||
>
|
||||
<img
|
||||
src={gif.previewUrl}
|
||||
alt={gif.title}
|
||||
className="w-full object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
style={{
|
||||
aspectRatio: gif.width && gif.height ? `${gif.width}/${gif.height}` : undefined,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{shown.map((gif) => {
|
||||
const isFavorite = favoriteIds.has(gif.id);
|
||||
return (
|
||||
// The star cannot live inside the tile button — a button inside
|
||||
// a button is invalid and swallows the click. Siblings instead.
|
||||
<div key={gif.id} className="relative group w-full mb-1.5 break-inside-avoid">
|
||||
<button
|
||||
onClick={() => onGifSelect(gif.url)}
|
||||
className="w-full rounded-lg overflow-hidden hover:ring-2 hover:ring-accent-primary transition-all block"
|
||||
>
|
||||
<img
|
||||
src={gif.previewUrl}
|
||||
alt={gif.title}
|
||||
className="w-full object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
style={{
|
||||
aspectRatio: gif.width && gif.height ? `${gif.width}/${gif.height}` : undefined,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => void toggleFavorite(gif, e)}
|
||||
title={isFavorite ? t('gif.favorite.remove') : t('gif.favorite.add')}
|
||||
aria-label={isFavorite ? t('gif.favorite.remove') : t('gif.favorite.add')}
|
||||
className={`absolute top-1.5 right-1.5 w-7 h-7 rounded-full flex items-center justify-center bg-black/55 backdrop-blur-sm transition-opacity ${
|
||||
isFavorite ? 'opacity-100 text-accent-amber' : 'opacity-0 group-hover:opacity-100 focus:opacity-100 text-white'
|
||||
}`}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill={isFavorite ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
||||
<path d="m12 17.27 6.18 3.73-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{loadingMore && (
|
||||
|
||||
Reference in New Issue
Block a user