1/**2 * Typed REST client for the PUBLIC social routes in @tribes/protocol api.ts:3 * browse, public script detail, leaderboard, model catalog, and hearts.4 * Same conventions as ./api (same-origin credentials, ApiError on !ok).5 */6import type {7 BrowseWindow,8 LeaderboardRow,9 ModelRecord,10 PublicScriptDetail,11 PublicScriptListing,12} from '@tribes/protocol';13import { ApiError } from './api';1415async function req<T>(method: string, path: string, body?: unknown): Promise<T> {16 const res = await fetch(path, {17 method,18 headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,19 body: body !== undefined ? JSON.stringify(body) : undefined,20 credentials: 'same-origin',21 });22 if (!res.ok) {23 let parsed: { code?: string; message?: string } | null = null;24 try {25 parsed = (await res.json()) as { code?: string; message?: string };26 } catch {27 /* non-JSON error body */28 }29 throw new ApiError(res.status, parsed);30 }31 if (res.status === 204) return undefined as T;32 const text = await res.text();33 return (text ? JSON.parse(text) : undefined) as T;34}3536export type BrowseSort = 'top' | 'rising' | 'updated' | 'battled';3738/** Client-side mirror of BrowseInput (all optional — server applies defaults). */39export interface BrowseQuery {40 sort?: BrowseSort;41 /** Window for 'rising' (rating gained). Ignored by other sorts. */42 window?: BrowseWindow;43 openWeights?: boolean;44 lab?: string;45 releasedAfter?: string; // YYYY-MM-DD46 releasedBefore?: string; // YYYY-MM-DD47 q?: string;48 limit?: number;49 offset?: number;50}5152/** GET /api/browse takes BrowseInput as query params, named as the zod fields. */53export function browseQueryString(input: BrowseQuery): string {54 const params = new URLSearchParams();55 const set = (key: string, value: string | number | boolean | undefined) => {56 if (value === undefined || value === '') return;57 params.set(key, String(value));58 };59 set('sort', input.sort);60 set('window', input.window);61 set('openWeights', input.openWeights);62 set('lab', input.lab);63 set('releasedAfter', input.releasedAfter);64 set('releasedBefore', input.releasedBefore);65 set('q', input.q);66 set('limit', input.limit);67 set('offset', input.offset);68 const qs = params.toString();69 return qs ? `?${qs}` : '';70}7172export interface ListModelsQuery {73 q?: string;74 lab?: string;75 openWeights?: boolean;76 limit?: number;77}7879export const social = {80 // browse (PUBLIC)81 browse: (input: BrowseQuery) =>82 req<PublicScriptListing[]>('GET', `/api/browse${browseQueryString(input)}`),83 getPublicScript: (id: string) =>84 req<PublicScriptDetail>('GET', `/api/browse/${encodeURIComponent(id)}`),8586 // leaderboard (PUBLIC)87 leaderboard: (window?: BrowseWindow, limit?: number) => {88 const params = new URLSearchParams();89 if (window) params.set('window', window);90 if (limit !== undefined) params.set('limit', String(limit));91 const qs = params.toString();92 return req<LeaderboardRow[]>('GET', `/api/leaderboard${qs ? `?${qs}` : ''}`);93 },9495 // model catalog (PUBLIC) — route map: ?q=&lab=&open=&limit=96 listModels: (input: ListModelsQuery = {}) => {97 const params = new URLSearchParams();98 if (input.q) params.set('q', input.q);99 if (input.lab) params.set('lab', input.lab);100 if (input.openWeights !== undefined) params.set('open', String(input.openWeights));101 if (input.limit !== undefined) params.set('limit', String(input.limit));102 const qs = params.toString();103 return req<ModelRecord[]>('GET', `/api/models${qs ? `?${qs}` : ''}`);104 },105106 // hearts (auth required)107 heart: (id: string) => req<void>('PUT', `/api/hearts/${encodeURIComponent(id)}`),108 unheart: (id: string) => req<void>('DELETE', `/api/hearts/${encodeURIComponent(id)}`),109 listHearts: () => req<PublicScriptListing[]>('GET', '/api/hearts'),110};