feat(gallery-reactions): Add gallery reactions API client with types and methods for managing reactions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-31 20:43:26 -07:00
parent 85135a6507
commit 7f969e8fbd
3 changed files with 93 additions and 0 deletions

View file

@ -0,0 +1,57 @@
import { resolveBaseUrl } from '../base-url';
import {
GALLERY_REACTION_MAX_DELTA,
type GalleryReactionMap,
type GalleryReactionPayload,
} from '../types/gallery-reaction';
/** Fetch all reaction counts for a provider's gallery. Uncached. */
export async function fetchGalleryReactions(provider = 'quinn'): Promise<GalleryReactionMap> {
const url = `${resolveBaseUrl()}/www/gallery-reactions?provider=${encodeURIComponent(provider)}`;
try {
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) return {};
const body = (await res.json()) as { reactions?: GalleryReactionMap };
return body.reactions ?? {};
} catch {
// The counter is non-critical decoration — never let it break the gallery.
return {};
}
}
function postReaction(url: string, provider: string, src: string, emoji: string, delta: number): void {
const body = JSON.stringify({ provider, src, emoji, delta });
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
const blob = new Blob([body], { type: 'application/json' });
if (navigator.sendBeacon(url, blob)) return;
}
void fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
}).catch(() => {
// fire-and-forget
});
}
/**
* Send a batched reaction delta. Fire-and-forget the UI already incremented
* optimistically, and a dropped tap on a public click-counter is non-fatal.
*
* A fast tapper can accumulate a delta larger than the server's per-request cap
* (GALLERY_REACTION_MAX_DELTA); we chunk it into capped pieces so every tap is
* counted server-side rather than rejected wholesale. Prefers sendBeacon so
* flushes survive page unload (mobile tab close / navigation).
*/
export function submitGalleryReaction(payload: GalleryReactionPayload): void {
const url = `${resolveBaseUrl()}/www/gallery-reactions`;
const provider = payload.provider ?? 'quinn';
let remaining = Math.floor(payload.delta);
if (!Number.isFinite(remaining) || remaining < 1) return;
while (remaining > 0) {
const chunk = Math.min(remaining, GALLERY_REACTION_MAX_DELTA);
postReaction(url, provider, payload.src, payload.emoji, chunk);
remaining -= chunk;
}
}

View file

@ -39,3 +39,11 @@ export {
fetchPseoTerm,
fetchPseoHobbyTerms,
} from './endpoints/pseo';
export type {
GalleryReactionEmoji,
GalleryReactionCounts,
GalleryReactionMap,
GalleryReactionPayload,
} from './types/gallery-reaction';
export { GALLERY_REACTION_EMOJIS, GALLERY_REACTION_MAX_DELTA } from './types/gallery-reaction';
export { fetchGalleryReactions, submitGalleryReaction } from './endpoints/gallery-reactions';

View file

@ -0,0 +1,28 @@
/**
* Gallery reactions anonymous, unlimited emoji "click counter" per photo.
* Mirror of the server allowlist in quinn-api (entities/gallery-reaction/types.ts);
* keep the palette and max-delta in sync.
*/
export const GALLERY_REACTION_EMOJIS = ['🔥', '😍', '💋', '🥵', '👅', '🍑', '🍆', '💦', '😈'] as const;
export type GalleryReactionEmoji = (typeof GALLERY_REACTION_EMOJIS)[number];
/**
* Largest delta a single POST may carry (server rejects more). The client chunks
* accumulated taps into pieces no larger than this so a fast tapper never loses
* counts to the server-side clamp.
*/
export const GALLERY_REACTION_MAX_DELTA = 50;
/** emoji → total taps for one photo. */
export type GalleryReactionCounts = Partial<Record<GalleryReactionEmoji, number>>;
/** Photo src (DB src / reactionKey) → counts. */
export type GalleryReactionMap = Record<string, GalleryReactionCounts>;
export interface GalleryReactionPayload {
readonly src: string;
readonly emoji: GalleryReactionEmoji;
readonly delta: number;
readonly provider?: string;
}