diff --git a/codebase/@packages/@lilith/provider-api-client/src/endpoints/gallery-reactions.ts b/codebase/@packages/@lilith/provider-api-client/src/endpoints/gallery-reactions.ts new file mode 100644 index 00000000..46043858 --- /dev/null +++ b/codebase/@packages/@lilith/provider-api-client/src/endpoints/gallery-reactions.ts @@ -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 { + 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; + } +} diff --git a/codebase/@packages/@lilith/provider-api-client/src/index.ts b/codebase/@packages/@lilith/provider-api-client/src/index.ts index 1be5677c..c39f7864 100644 --- a/codebase/@packages/@lilith/provider-api-client/src/index.ts +++ b/codebase/@packages/@lilith/provider-api-client/src/index.ts @@ -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'; diff --git a/codebase/@packages/@lilith/provider-api-client/src/types/gallery-reaction.ts b/codebase/@packages/@lilith/provider-api-client/src/types/gallery-reaction.ts new file mode 100644 index 00000000..eccafdf0 --- /dev/null +++ b/codebase/@packages/@lilith/provider-api-client/src/types/gallery-reaction.ts @@ -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>; + +/** Photo src (DB src / reactionKey) → counts. */ +export type GalleryReactionMap = Record; + +export interface GalleryReactionPayload { + readonly src: string; + readonly emoji: GalleryReactionEmoji; + readonly delta: number; + readonly provider?: string; +}