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:
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `gif_favorites` (
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`gif_id` text NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`preview_url` text NOT NULL,
|
||||||
|
`url` text NOT NULL,
|
||||||
|
`width` integer NOT NULL,
|
||||||
|
`height` integer NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
PRIMARY KEY(`user_id`, `gif_id`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_gif_favorites_user_id` ON `gif_favorites` (`user_id`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
|||||||
"when": 1788190305853,
|
"when": 1788190305853,
|
||||||
"tag": "0011_lethal_bruce_banner",
|
"tag": "0011_lethal_bruce_banner",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788192490711,
|
||||||
|
"tag": "0012_sour_pixie",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -566,3 +566,24 @@ export const spotifyConnections = sqliteTable('spotify_connections', {
|
|||||||
spotifyUserId: text('spotify_user_id'),
|
spotifyUserId: text('spotify_user_id'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favourited GIFs, one row per user per GIF.
|
||||||
|
*
|
||||||
|
* Stores the whole result rather than an id: the provider offers no lookup by
|
||||||
|
* id, so a favourites tab that only kept ids could not render without
|
||||||
|
* re-searching for something the user may never find again.
|
||||||
|
*/
|
||||||
|
export const gifFavorites = sqliteTable('gif_favorites', {
|
||||||
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
gifId: text('gif_id').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
previewUrl: text('preview_url').notNull(),
|
||||||
|
url: text('url').notNull(),
|
||||||
|
width: integer('width').notNull(),
|
||||||
|
height: integer('height').notNull(),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.userId, table.gifId] }),
|
||||||
|
userIdx: index('idx_gif_favorites_user_id').on(table.userId),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
import type { GifResult } from '@backspace/shared';
|
import type { GifResult } from '@backspace/shared';
|
||||||
@@ -178,4 +178,68 @@ export async function gifRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(200).send({ results: [], next: '' });
|
return reply.code(200).send({ results: [], next: '' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Favourites ──────────────────────────────────────────────────────────
|
||||||
|
// Kept per user and synced server-side so a favourite made on the phone is
|
||||||
|
// there on the desktop, which is the whole point of favouriting.
|
||||||
|
|
||||||
|
/** Cap per user: a favourites tab is a shortlist, not an archive. */
|
||||||
|
const MAX_FAVORITES = 200;
|
||||||
|
|
||||||
|
app.get('/api/gif/favorites', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.select().from(schema.gifFavorites)
|
||||||
|
.where(eq(schema.gifFavorites.userId, request.userId))
|
||||||
|
.orderBy(desc(schema.gifFavorites.createdAt))
|
||||||
|
.all();
|
||||||
|
const results: GifResult[] = rows.map((r) => ({
|
||||||
|
id: r.gifId, title: r.title, previewUrl: r.previewUrl,
|
||||||
|
url: r.url, width: r.width, height: r.height,
|
||||||
|
}));
|
||||||
|
return reply.code(200).send({ results });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: GifResult }>('/api/gif/favorites', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id, title, previewUrl, url, width, height } = request.body ?? ({} as GifResult);
|
||||||
|
if (!id || typeof id !== 'string' || !previewUrl || !url) {
|
||||||
|
return reply.code(400).send({ error: 'id, previewUrl and url are required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
// Only http(s): these become <img src> for everyone who opens the picker.
|
||||||
|
for (const candidate of [previewUrl, url]) {
|
||||||
|
if (!/^https?:\/\//.test(candidate)) {
|
||||||
|
return reply.code(400).send({ error: 'previewUrl and url must be http(s)', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.select().from(schema.gifFavorites)
|
||||||
|
.where(eq(schema.gifFavorites.userId, request.userId)).all().length;
|
||||||
|
const existing = db.select().from(schema.gifFavorites)
|
||||||
|
.where(and(eq(schema.gifFavorites.userId, request.userId), eq(schema.gifFavorites.gifId, id))).get();
|
||||||
|
if (!existing && count >= MAX_FAVORITES) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_FAVORITES} favourites`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.insert(schema.gifFavorites).values({
|
||||||
|
userId: request.userId,
|
||||||
|
gifId: id,
|
||||||
|
title: typeof title === 'string' ? title.slice(0, 200) : '',
|
||||||
|
previewUrl,
|
||||||
|
url,
|
||||||
|
width: Number.isFinite(width) ? width : 0,
|
||||||
|
height: Number.isFinite(height) ? height : 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).onConflictDoNothing().run();
|
||||||
|
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/gif/favorites/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
db.delete(schema.gifFavorites)
|
||||||
|
.where(and(eq(schema.gifFavorites.userId, request.userId), eq(schema.gifFavorites.gifId, request.params.id)))
|
||||||
|
.run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,9 @@ export class BackspaceApiClient {
|
|||||||
trending: (limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>;
|
trending: (limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>;
|
||||||
search: (q: string, 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 }>;
|
enabled: () => Promise<{ enabled: boolean }>;
|
||||||
|
favorites: () => Promise<{ results: GifResult[] }>;
|
||||||
|
addFavorite: (gif: GifResult) => Promise<void>;
|
||||||
|
removeFavorite: (id: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly spotify: {
|
readonly spotify: {
|
||||||
@@ -710,6 +713,9 @@ export class BackspaceApiClient {
|
|||||||
if (pos) params.set('pos', pos);
|
if (pos) params.set('pos', pos);
|
||||||
return request<{ results: GifResult[]; next: string }>('GET', `/gif/trending?${params}`);
|
return request<{ results: GifResult[]; next: string }>('GET', `/gif/trending?${params}`);
|
||||||
},
|
},
|
||||||
|
favorites: () => request<{ results: GifResult[] }>('GET', '/gif/favorites'),
|
||||||
|
addFavorite: (gif: GifResult) => request<void>('POST', '/gif/favorites', gif),
|
||||||
|
removeFavorite: (id: string) => request<void>('DELETE', `/gif/favorites/${encodeURIComponent(id)}`),
|
||||||
search: (q: string, limit = 30, pos?: string) => {
|
search: (q: string, limit = 30, pos?: string) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('q', q);
|
params.set('q', q);
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { GifResult } from '@backspace/shared';
|
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 {
|
interface GifPickerProps {
|
||||||
onGifSelect: (url: string) => void;
|
onGifSelect: (url: string) => void;
|
||||||
@@ -12,6 +27,10 @@ interface GifPickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GifPicker({ onGifSelect, mobile = false }: 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 [query, setQuery] = useState('');
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||||
const [results, setResults] = useState<GifResult[]>([]);
|
const [results, setResults] = useState<GifResult[]>([]);
|
||||||
@@ -21,6 +40,46 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
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
|
// Debounce search query
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
@@ -60,6 +119,8 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
// Infinite scroll
|
// Infinite scroll
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
|
// Favourites are a complete local list — nothing to page through.
|
||||||
|
if (showFavorites) return;
|
||||||
if (!el || loadingMore || !nextPos) return;
|
if (!el || loadingMore || !nextPos) return;
|
||||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
@@ -76,13 +137,16 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
};
|
};
|
||||||
fetchMore();
|
fetchMore();
|
||||||
}
|
}
|
||||||
}, [loadingMore, nextPos, debouncedQuery]);
|
}, [loadingMore, nextPos, debouncedQuery, showFavorites]);
|
||||||
|
|
||||||
// Prevent keyboard events from bubbling
|
// Prevent keyboard events from bubbling
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
e.stopPropagation();
|
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
|
// Mobile: fill parent (sheet sets width + max-height). Desktop: fixed dims
|
||||||
// matching the legacy popover footprint.
|
// matching the legacy popover footprint.
|
||||||
const rootClass = mobile
|
const rootClass = mobile
|
||||||
@@ -97,7 +161,7 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search GIFs"
|
placeholder={t('gif.search')}
|
||||||
className="input-search w-full"
|
className="input-search w-full"
|
||||||
// Auto-focus only on desktop. On mobile this would force the OS
|
// Auto-focus only on desktop. On mobile this would force the OS
|
||||||
// keyboard up the moment the sheet opens, hiding most of the grid.
|
// keyboard up the moment the sheet opens, hiding most of the grid.
|
||||||
@@ -105,13 +169,41 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Results grid */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-1"
|
className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-1"
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading && !showFavorites ? (
|
||||||
<div className="grid grid-cols-2 gap-1.5 p-1">
|
<div className="grid grid-cols-2 gap-1.5 p-1">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -121,29 +213,52 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : results.length === 0 ? (
|
) : shown.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm">
|
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm text-center px-4">
|
||||||
{debouncedQuery.trim() ? 'No GIFs found' : 'No trending GIFs'}
|
{showFavorites
|
||||||
|
? t('gif.empty.favorites')
|
||||||
|
: debouncedQuery.trim()
|
||||||
|
? t('gif.empty.search')
|
||||||
|
: t('gif.empty.trending')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="columns-2 gap-1.5 p-1">
|
<div className="columns-2 gap-1.5 p-1">
|
||||||
{results.map((gif) => (
|
{shown.map((gif) => {
|
||||||
<button
|
const isFavorite = favoriteIds.has(gif.id);
|
||||||
key={gif.id}
|
return (
|
||||||
onClick={() => onGifSelect(gif.url)}
|
// The star cannot live inside the tile button — a button inside
|
||||||
className="w-full mb-1.5 rounded-lg overflow-hidden hover:ring-2 hover:ring-accent-primary transition-all break-inside-avoid"
|
// 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">
|
||||||
<img
|
<button
|
||||||
src={gif.previewUrl}
|
onClick={() => onGifSelect(gif.url)}
|
||||||
alt={gif.title}
|
className="w-full rounded-lg overflow-hidden hover:ring-2 hover:ring-accent-primary transition-all block"
|
||||||
className="w-full object-cover rounded-lg"
|
>
|
||||||
loading="lazy"
|
<img
|
||||||
style={{
|
src={gif.previewUrl}
|
||||||
aspectRatio: gif.width && gif.height ? `${gif.width}/${gif.height}` : undefined,
|
alt={gif.title}
|
||||||
}}
|
className="w-full object-cover rounded-lg"
|
||||||
/>
|
loading="lazy"
|
||||||
</button>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
|
|||||||
@@ -33,6 +33,22 @@ export const en = {
|
|||||||
'settings.voice.micTest.idle': 'Test your mic without joining a call.',
|
'settings.voice.micTest.idle': 'Test your mic without joining a call.',
|
||||||
'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.',
|
'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.',
|
||||||
|
|
||||||
|
// GIF picker
|
||||||
|
'gif.search': 'Search GIFs',
|
||||||
|
'gif.tab.favorites': 'Favorites',
|
||||||
|
'gif.tab.trending': 'Trending',
|
||||||
|
'gif.empty.search': 'No GIFs found',
|
||||||
|
'gif.empty.trending': 'No trending GIFs',
|
||||||
|
'gif.empty.favorites': 'No favorites yet — tap the star on any GIF.',
|
||||||
|
'gif.favorite.add': 'Add to favorites',
|
||||||
|
'gif.favorite.remove': 'Remove from favorites',
|
||||||
|
'gif.category.hello': 'hello',
|
||||||
|
'gif.category.lol': 'lol',
|
||||||
|
'gif.category.love': 'love',
|
||||||
|
'gif.category.birthday': 'happy birthday',
|
||||||
|
'gif.category.dance': 'dance',
|
||||||
|
'gif.category.facepalm': 'facepalm',
|
||||||
|
|
||||||
// Settings — privacy
|
// Settings — privacy
|
||||||
'privacy.title': 'Privacy',
|
'privacy.title': 'Privacy',
|
||||||
'privacy.discoverable.label': 'Allow others to find my profile',
|
'privacy.discoverable.label': 'Allow others to find my profile',
|
||||||
|
|||||||
@@ -32,6 +32,22 @@ export const ptBR: Partial<Dictionary> = {
|
|||||||
'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.',
|
'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.',
|
||||||
'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.',
|
'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.',
|
||||||
|
|
||||||
|
// Seletor de GIF
|
||||||
|
'gif.search': 'Buscar GIFs',
|
||||||
|
'gif.tab.favorites': 'Favoritos',
|
||||||
|
'gif.tab.trending': 'Em alta',
|
||||||
|
'gif.empty.search': 'Nenhum GIF encontrado',
|
||||||
|
'gif.empty.trending': 'Nenhum GIF em alta',
|
||||||
|
'gif.empty.favorites': 'Nenhum favorito ainda — toque na estrela de um GIF.',
|
||||||
|
'gif.favorite.add': 'Adicionar aos favoritos',
|
||||||
|
'gif.favorite.remove': 'Remover dos favoritos',
|
||||||
|
'gif.category.hello': 'oi',
|
||||||
|
'gif.category.lol': 'risada',
|
||||||
|
'gif.category.love': 'amor',
|
||||||
|
'gif.category.birthday': 'feliz aniversário',
|
||||||
|
'gif.category.dance': 'dança',
|
||||||
|
'gif.category.facepalm': 'vergonha alheia',
|
||||||
|
|
||||||
// Configurações — privacidade
|
// Configurações — privacidade
|
||||||
'privacy.title': 'Privacidade',
|
'privacy.title': 'Privacidade',
|
||||||
'privacy.discoverable.label': 'Permitir que me encontrem',
|
'privacy.discoverable.label': 'Permitir que me encontrem',
|
||||||
|
|||||||
Reference in New Issue
Block a user