← back to CelebritySignatures
apps/mobile/api.ts
130 lines
// Thin typed client over the existing celebsignatures.com Express JSON APIs.
// Native app keeps the backend on Kamatera (no rewrite) — see plan. RN has its
// own cookie jar, so the cs_sess auth cookie set by /api/auth/* is retained
// across fetches automatically (no CORS constraints apply to a native client).
import Constants from 'expo-constants';
import * as WebBrowser from 'expo-web-browser';
export const WEB_BASE: string =
(Constants.expoConfig?.extra as any)?.webBase ?? 'https://celebsignatures.com';
/**
* Sign in with Apple (native). Apple/Google reject OAuth inside an embedded
* WebView, so we run the web sign-in flow in ASWebAuthenticationSession, then
* replay the one-time handoff token from THIS native fetch — so the cs_sess
* cookie lands in RN's shared cookie jar (the same jar every api call + the
* checkout WebView use). Returns {ok:false, error:'canceled'} on user cancel.
*/
export async function appleSignIn(): Promise<{ ok: boolean; error?: string }> {
try {
const result = await WebBrowser.openAuthSessionAsync(
`${WEB_BASE}/auth/apple/login?app=1`,
'celebsignatures://auth',
);
if (result.type === 'cancel' || result.type === 'dismiss') return { ok: false, error: 'canceled' };
if (result.type !== 'success' || !result.url) return { ok: false, error: 'Sign in was not completed.' };
const token = new URL(result.url).searchParams.get('token');
if (!token) return { ok: false, error: 'No sign-in token returned.' };
// Replay the handoff (302 + Set-Cookie); RN's fetch stores the cookie from
// the redirect chain into the shared native jar.
await fetch(`${WEB_BASE}/auth/handoff?token=${encodeURIComponent(token)}`);
return { ok: true };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}
export type Signature = {
category: string;
rank?: number;
full_name: string;
wikidata?: string;
reason_for_ranking?: string;
signature_image_url?: string;
image_license?: string;
image_author?: string;
deceased?: string;
death_date?: string;
museums?: string[];
};
export type EvolutionEntry = { file: string; year: number | null; source: string; url: string; license: string };
export type Evolution = Record<string, { name: string; sigs: EvolutionEntry[] }>;
export type Portraits = Record<string, string>;
export type Mural = {
code: string; slug: string; title: string; blurb?: string; sigs?: number;
roster?: string[]; met_accent?: string;
};
export type MuralsCatalog = {
pricePerSqFt: number;
size: { widthFt: number; heightFt: number; dpi: number; pxW: number; pxH: number; sqft: number };
murals: Mural[];
};
export type User = { email: string; name?: string } | null;
export type LeaderRow = { id?: string; name: string; score: number; at: string };
/** Wikidata QID from a `.../Q12345` url — the key portraits + evolution use. */
export function qidOf(sig: Signature): string | null {
const m = (sig.wikidata || '').match(/Q\d+/);
return m ? m[0] : null;
}
/**
* RN's <Image> cannot decode SVG (62 of the 1,554 signatures, incl. Jefferson),
* so route Commons SVG originals through Special:FilePath?width=N, which 302s
* to a rasterized PNG thumb (the same mechanism the evolution data already
* uses; hand-building …/thumb/… paths 400s on some shards). Raster originals
* pass through untouched — they decode natively and the thumb service errors
* when asked to upscale a bitmap past its native width.
*/
export function sigImg(url?: string, width = 640): string | undefined {
if (!url) return undefined;
const m = url.match(
/^https?:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/[0-9a-f]\/[0-9a-f]{2}\/([^/?#]+\.svg)$/i,
);
if (!m) return url;
return `https://commons.wikimedia.org/wiki/Special:FilePath/${m[1]}?width=${width}`;
}
async function getJSON<T>(path: string): Promise<T> {
const r = await fetch(`${WEB_BASE}${path}`);
if (!r.ok) throw new Error(`${path} → ${r.status}`);
return r.json() as Promise<T>;
}
async function postJSON<T>(path: string, body: unknown): Promise<T> {
const r = await fetch(`${WEB_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return r.json() as Promise<T>;
}
export const api = {
signatures: () => getJSON<Signature[]>('/api/signatures'),
portraits: () => getJSON<Portraits>('/api/portraits'),
evolution: () => getJSON<Evolution>('/api/signature-evolution'),
murals: () => getJSON<MuralsCatalog>('/api/murals-catalog'),
leaderboardTop: (game: string, diff: string) =>
getJSON<{ ok: boolean; top: LeaderRow[] }>(`/api/leaderboard?game=${game}&diff=${diff}`),
leaderboardSubmit: (game: string, diff: string, score: number, name: string) =>
postJSON<{ ok: boolean; rank?: number; top?: LeaderRow[] }>('/api/leaderboard', { game, diff, score, name }),
// Which sign-in methods are live server-side (Apple button hides until configured).
authConfig: () => getJSON<{ ok: boolean; apple: boolean }>('/api/auth-config'),
me: () => getJSON<{ ok: boolean; user: User }>('/api/auth/me'),
login: (email: string, password: string) =>
postJSON<{ ok: boolean; user?: User; error?: string }>('/api/auth/login', { email, password }),
signup: (email: string, password: string, name?: string) =>
postJSON<{ ok: boolean; user?: User; error?: string }>('/api/auth/signup', { email, password, name }),
logout: () => postJSON<{ ok: boolean }>('/api/auth/logout', {}),
// account deletion — Apple 5.1.1(v). Backend endpoint added in the same effort.
deleteAccount: () => postJSON<{ ok: boolean; error?: string }>('/api/auth/delete', {}),
muralOrder: (body: { name: string; email: string; mural?: string; note?: string }) =>
postJSON<{ ok: boolean; id?: string }>('/api/mural-order', body),
};