diff --git a/.gitignore b/.gitignore index a529e692a5..1683594709 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ downloads/ eggs/ .eggs/ lib/ +# Python build-artifact rule above — the frontend API client lives in app/src/lib/ +!app/src/lib/ lib64/ parts/ sdist/ diff --git a/app/src/components/FeedbackWidget.tsx b/app/src/components/FeedbackWidget.tsx index 30a315937d..f093cf6c27 100644 --- a/app/src/components/FeedbackWidget.tsx +++ b/app/src/components/FeedbackWidget.tsx @@ -16,9 +16,9 @@ import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import Tooltip from '@mui/material/Tooltip'; -import { API_URL } from 'src/constants'; import { useAnalytics } from 'src/hooks'; import { useLocalStorage } from 'src/hooks/useLocalStorage'; +import { apiPost, endpoints } from 'src/lib/api'; import { RESERVED_TOP_LEVEL } from 'src/utils/paths'; const MAX_MESSAGE_LENGTH = 500; @@ -224,12 +224,10 @@ export function FeedbackWidget() { // the row was never written. setMode('closed'); try { - const response = await fetch(`${API_URL}/feedback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildPayload({ message: null, reaction: r, contact: null })), - }); - if (!response.ok) return; + await apiPost( + endpoints.feedback, + buildPayload({ message: null, reaction: r, contact: null }) + ); setThanksVisible(true); trackEvent('feedback_submitted', { path: getCurrentPath() || undefined, @@ -239,8 +237,8 @@ export function FeedbackWidget() { mode: 'quick', }); } catch { - // Network failure — drop silently. The quick interaction has no error UI - // surface (we closed the FAB optimistically); the user can retry. + // Non-2xx or network failure — drop silently. The quick interaction has + // no error UI surface (we closed the FAB optimistically); the user can retry. } }; @@ -260,17 +258,10 @@ export function FeedbackWidget() { setError(null); try { - const response = await fetch(`${API_URL}/feedback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( - buildPayload({ message: trimmed || null, reaction, contact: contact.trim() || null }) - ), - }); - - if (!response.ok) { - throw new Error(`status ${response.status}`); - } + await apiPost( + endpoints.feedback, + buildPayload({ message: trimmed || null, reaction, contact: contact.trim() || null }) + ); trackEvent('feedback_submitted', { path: getCurrentPath() || undefined, diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx index 9e7b95d134..556c7fcda3 100644 --- a/app/src/components/Layout.tsx +++ b/app/src/components/Layout.tsx @@ -1,6 +1,5 @@ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; -import { API_URL } from 'src/constants'; import { AppDataContext, type HomeState, @@ -9,6 +8,7 @@ import { ThemeContext, } from 'src/hooks/useLayoutContext'; import { useThemeMode } from 'src/hooks/useThemeMode'; +import { ApiError, apiGet, endpoints } from 'src/lib/api'; import type { LanguageInfo, LibraryInfo, SpecInfo } from 'src/types'; // Global provider that wraps the entire router @@ -51,35 +51,35 @@ export function AppDataProvider({ children }: { children: ReactNode }) { const signal = abortController.signal; const load = async () => { + // Non-ok responses used to be skipped per endpoint (res.ok check) while + // the other setters still ran; map ApiError to null to keep that, and + // rethrow everything else (network errors, aborts) so the whole load + // lands in the outer catch like before. + const safeGet = async (path: string): Promise => { + try { + return await apiGet(path, { signal }); + } catch (err) { + if (err instanceof ApiError) return null; + throw err; + } + }; + try { - const [specsRes, libsRes, langsRes, statsRes] = await Promise.all([ - fetch(`${API_URL}/specs`, { signal }), - fetch(`${API_URL}/libraries`, { signal }), - fetch(`${API_URL}/languages`, { signal }), - fetch(`${API_URL}/stats`, { signal }), + const [specsBody, libsBody, langsBody, statsBody] = await Promise.all([ + safeGet(endpoints.specs), + safeGet<{ libraries?: LibraryInfo[] }>(endpoints.libraries), + safeGet<{ languages?: LanguageInfo[] }>(endpoints.languages), + safeGet<{ specs: number; plots: number; libraries: number; lines_of_code?: number }>( + endpoints.stats + ), ]); if (signal.aborted) return; - if (specsRes.ok) { - const data = await specsRes.json(); - if (!signal.aborted) setSpecsData(Array.isArray(data) ? data : data.specs || []); - } - - if (libsRes.ok) { - const data = await libsRes.json(); - if (!signal.aborted) setLibrariesData(data.libraries || []); - } - - if (langsRes.ok) { - const data = await langsRes.json(); - if (!signal.aborted) setLanguagesData(data.languages || []); - } - - if (statsRes.ok) { - const data = await statsRes.json(); - if (!signal.aborted) setStats(data); - } + if (specsBody) setSpecsData(Array.isArray(specsBody) ? specsBody : specsBody.specs || []); + if (libsBody) setLibrariesData(libsBody.libraries || []); + if (langsBody) setLanguagesData(langsBody.languages || []); + if (statsBody) setStats(statsBody); } catch (err) { if (signal.aborted) return; console.warn('Initial data load incomplete:', err instanceof Error ? err.message : err); diff --git a/app/src/components/PlotOfTheDay.tsx b/app/src/components/PlotOfTheDay.tsx index 114b9e4680..fa8682d08f 100644 --- a/app/src/components/PlotOfTheDay.tsx +++ b/app/src/components/PlotOfTheDay.tsx @@ -9,9 +9,10 @@ import IconButton from '@mui/material/IconButton'; import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; -import { API_URL, GITHUB_URL } from 'src/constants'; +import { GITHUB_URL } from 'src/constants'; import { useAnalytics } from 'src/hooks'; import { useTheme } from 'src/hooks/useLayoutContext'; +import { apiGet, endpoints } from 'src/lib/api'; import { colors, fontSize, semanticColors, typography } from 'src/theme'; import { specPath } from 'src/utils/paths'; import { buildSrcSet, getFallbackSrc } from 'src/utils/responsiveImage'; @@ -49,11 +50,7 @@ export function PlotOfTheDay() { useEffect(() => { if (dismissed) return; - fetch(`${API_URL}/insights/plot-of-the-day`) - .then(r => { - if (!r.ok) throw new Error(); - return r.json(); - }) + apiGet(endpoints.plotOfTheDay) .then(setData) .catch(() => {}) .finally(() => setLoading(false)); diff --git a/app/src/components/RelatedSpecs.tsx b/app/src/components/RelatedSpecs.tsx index 1506deef99..28978460a0 100644 --- a/app/src/components/RelatedSpecs.tsx +++ b/app/src/components/RelatedSpecs.tsx @@ -9,8 +9,9 @@ import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; import Typography from '@mui/material/Typography'; -import { API_URL, LIB_ABBREV } from 'src/constants'; +import { LIB_ABBREV } from 'src/constants'; import { useTheme } from 'src/hooks/useLayoutContext'; +import { apiGet, endpoints } from 'src/lib/api'; import { colors, fontSize, semanticColors, typography } from 'src/theme'; import { specPath } from 'src/utils/paths'; import { buildSrcSet, getFallbackSrc } from 'src/utils/responsiveImage'; @@ -62,11 +63,7 @@ export function RelatedSpecs({ specId, mode = 'spec', library, onHoverTags }: Re let cancelled = false; const params = new URLSearchParams({ limit: '24', mode }); if (library && mode === 'full') params.set('library', library); - fetch(`${API_URL}/insights/related/${specId}?${params}`) - .then(r => { - if (!r.ok) throw new Error(); - return r.json(); - }) + apiGet<{ related?: RelatedSpec[] }>(endpoints.relatedSpecs(specId, params.toString())) .then(data => { if (!cancelled) { setRelated(data.related ?? []); diff --git a/app/src/components/SpecTabs.tsx b/app/src/components/SpecTabs.tsx index 34645af96a..728136e08d 100644 --- a/app/src/components/SpecTabs.tsx +++ b/app/src/components/SpecTabs.tsx @@ -20,7 +20,7 @@ import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; const CodeHighlighter = lazy(() => import('src/components/CodeHighlighter')); -import { API_URL } from 'src/constants'; +import { apiGet, endpoints } from 'src/lib/api'; import { colors, fontSize, semanticColors, typography } from 'src/theme'; // Cached global tag counts — loaded once, shared across all SpecTabs instances @@ -198,8 +198,12 @@ export function SpecTabs({ useEffect(() => { if (cachedTagCounts) return; const controller = new AbortController(); - fetch(`${API_URL}/plots/filter?limit=1`, { signal: controller.signal }) - .then(r => (r.ok ? r.json() : null)) + // Non-ok responses previously resolved to null and were ignored; apiGet + // throws instead, so the empty catch keeps the same silent-skip behavior. + apiGet<{ globalCounts?: Record> }>( + endpoints.plotsFilter('limit=1'), + { signal: controller.signal } + ) .then(data => { if (data?.globalCounts) { cachedTagCounts = data.globalCounts; diff --git a/app/src/hooks/useCodeFetch.test.ts b/app/src/hooks/useCodeFetch.test.ts index 327269039e..b6438edc34 100644 --- a/app/src/hooks/useCodeFetch.test.ts +++ b/app/src/hooks/useCodeFetch.test.ts @@ -52,7 +52,8 @@ describe('useCodeFetch', () => { expect(code).toBe(mplCode); expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining('/specs/scatter-basic/matplotlib/code') + expect.stringContaining('/specs/scatter-basic/matplotlib/code'), + undefined ); }); @@ -178,7 +179,8 @@ describe('useCodeFetch', () => { expect(code).toBe(ggCode); expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining('/specs/scatter-basic/ggplot2/code?language=r') + expect.stringContaining('/specs/scatter-basic/ggplot2/code?language=r'), + undefined ); }); @@ -197,7 +199,8 @@ describe('useCodeFetch', () => { expect(code).toBe(jlCode); expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining('/specs/scatter-basic/makie/code?language=julia') + expect.stringContaining('/specs/scatter-basic/makie/code?language=julia'), + undefined ); }); @@ -216,7 +219,8 @@ describe('useCodeFetch', () => { expect(code).toBe(tsxCode); expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining('/specs/scatter-basic/muix/code?language=javascript') + expect.stringContaining('/specs/scatter-basic/muix/code?language=javascript'), + undefined ); }); diff --git a/app/src/hooks/useCodeFetch.ts b/app/src/hooks/useCodeFetch.ts index 98f3026de4..493888630d 100644 --- a/app/src/hooks/useCodeFetch.ts +++ b/app/src/hooks/useCodeFetch.ts @@ -7,7 +7,7 @@ import { useCallback, useRef, useState } from 'react'; -import { API_URL } from 'src/constants'; +import { apiGet, endpoints } from 'src/lib/api'; interface CodeCache { [key: string]: string | null; // key: `${spec_id}:${language}:${library}` @@ -25,7 +25,10 @@ const cacheKey = (specId: string, library: string, language: string) => `${specId}:${language}:${library}`; export function useCodeFetch(): UseCodeFetchReturn { - const [isLoading, setIsLoading] = useState(false); + // Count in-flight requests instead of a boolean: with overlapping fetches + // (different cache keys) the first completion must not clear the loading + // state while the second request is still pending. + const [pendingCount, setPendingCount] = useState(0); const cacheRef = useRef({}); const pendingRef = useRef>>(new Map()); @@ -55,23 +58,12 @@ export function useCodeFetch(): UseCodeFetchReturn { return pending; } - // Only append the language query param when it diverges from the API - // default — keeps URLs for the common Python case unchanged. - const url = - language === 'python' - ? `${API_URL}/specs/${specId}/${library}/code` - : `${API_URL}/specs/${specId}/${library}/code?language=${encodeURIComponent(language)}`; - - setIsLoading(true); + setPendingCount(count => count + 1); const promise = (async () => { try { - const response = await fetch(url); - if (!response.ok) { - cacheRef.current[key] = null; - return null; - } - - const data = await response.json(); + const data = await apiGet<{ code?: string | null }>( + endpoints.code(specId, library, language) + ); const code = data.code ?? null; cacheRef.current[key] = code; return code; @@ -80,7 +72,7 @@ export function useCodeFetch(): UseCodeFetchReturn { return null; } finally { pendingRef.current.delete(key); - setIsLoading(false); + setPendingCount(count => count - 1); } })(); @@ -90,5 +82,5 @@ export function useCodeFetch(): UseCodeFetchReturn { [] ); - return { fetchCode, getCode, isLoading }; + return { fetchCode, getCode, isLoading: pendingCount > 0 }; } diff --git a/app/src/hooks/useFeaturedSpecs.ts b/app/src/hooks/useFeaturedSpecs.ts index 6ef387021b..bc07ae92f5 100644 --- a/app/src/hooks/useFeaturedSpecs.ts +++ b/app/src/hooks/useFeaturedSpecs.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; -import { API_URL } from 'src/constants'; import { useAppData } from 'src/hooks/useLayoutContext'; +import { apiGet, endpoints } from 'src/lib/api'; import type { PlotImage } from 'src/types'; import { shuffleArray } from 'src/utils/shuffle'; @@ -35,10 +35,9 @@ export function useFeaturedSpecs(count: number = 5): FeaturedImpl[] | null { useEffect(() => { let cancelled = false; - fetch(`${API_URL}/plots/filter`) - .then(r => (r.ok ? r.json() : null)) - .then((data: { images?: PlotImage[] } | null) => { - if (cancelled || !data?.images) return; + apiGet<{ images?: PlotImage[] }>(endpoints.plotsFilter()) + .then(data => { + if (cancelled || !data.images) return; setImages(data.images); }) .catch(() => {}); diff --git a/app/src/hooks/useFilterFetch.ts b/app/src/hooks/useFilterFetch.ts index 9a0c7f43a5..db3d9704f2 100644 --- a/app/src/hooks/useFilterFetch.ts +++ b/app/src/hooks/useFilterFetch.ts @@ -6,7 +6,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { API_URL, BATCH_SIZE } from 'src/constants'; +import { BATCH_SIZE } from 'src/constants'; +import { apiGet, endpoints } from 'src/lib/api'; import type { ActiveFilters, FilterCounts, PlotImage } from 'src/types'; import { shuffleArray } from 'src/utils/shuffle'; @@ -96,13 +97,13 @@ export function useFilterFetch({ } }); - const queryString = params.toString(); - const url = `${API_URL}/plots/filter${queryString ? `?${queryString}` : ''}`; - - const response = await fetch(url, { signal: abortController.signal }); - if (!response.ok) throw new Error('Failed to fetch filtered plots'); - - const data = await response.json(); + const data = await apiGet<{ + counts: FilterCounts; + globalCounts?: FilterCounts; + orCounts?: Record[]; + specTitles?: Record; + images?: PlotImage[]; + }>(endpoints.plotsFilter(params.toString()), { signal: abortController.signal }); if (abortController.signal.aborted) return; diff --git a/app/src/hooks/usePlotOfTheDay.ts b/app/src/hooks/usePlotOfTheDay.ts index cfc8f82ebe..30f2c65f28 100644 --- a/app/src/hooks/usePlotOfTheDay.ts +++ b/app/src/hooks/usePlotOfTheDay.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { API_URL } from 'src/constants'; +import { apiGet, endpoints } from 'src/lib/api'; export interface PlotOfTheDayData { spec_id: string; @@ -26,12 +26,8 @@ export function usePlotOfTheDay(): PlotOfTheDayData | null { useEffect(() => { let cancelled = false; - fetch(`${API_URL}/insights/plot-of-the-day`) - .then(r => { - if (!r.ok) throw new Error(`${r.status}`); - return r.json(); - }) - .then((data: PlotOfTheDayData) => { + apiGet(endpoints.plotOfTheDay) + .then(data => { if (!cancelled) setPotd(data); }) .catch(() => {}); diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts new file mode 100644 index 0000000000..26d9be22ac --- /dev/null +++ b/app/src/lib/api.ts @@ -0,0 +1,86 @@ +/** + * Central API client for the anyplot backend. + * + * Thin wrapper around fetch: builds URLs from CONFIG.api.baseUrl via the + * `endpoints` registry and raises ApiError on non-2xx responses. Callers keep + * their own caching/abort/dedup strategies (see useCodeFetch/useFilterFetch). + */ + +import { CONFIG } from 'src/global-config'; + +export class ApiError extends Error { + readonly status: number; + readonly url: string; + + constructor(status: number, statusText: string, url: string) { + super(`API request failed: ${status} ${statusText} (${url})`); + this.name = 'ApiError'; + this.status = status; + this.url = url; + } +} + +export const endpoints = { + // Only append the language query param when it diverges from the API + // default — keeps URLs for the common Python case unchanged. + code: (specId: string, library: string, language = 'python') => + language === 'python' + ? `/specs/${specId}/${library}/code` + : `/specs/${specId}/${library}/code?language=${encodeURIComponent(language)}`, + download: (specId: string, library: string) => `/download/${specId}/${library}`, + feedback: '/feedback', + insightsDashboard: '/insights/dashboard', + insightsVisitors: '/insights/visitors', + languages: '/languages', + libraries: '/libraries', + plotOfTheDay: '/insights/plot-of-the-day', + plotsFilter: (queryString = '') => `/plots/filter${queryString ? `?${queryString}` : ''}`, + relatedSpecs: (specId: string, queryString: string) => + `/insights/related/${specId}?${queryString}`, + spec: (specId: string) => `/specs/${specId}`, + specs: '/specs', + specsMap: '/specs/map', + stats: '/stats', +} as const; + +export function apiUrl(path: string): string { + return `${CONFIG.api.baseUrl}${path}`; +} + +/** GET a JSON payload; throws ApiError on non-2xx responses. */ +export async function apiGet(path: string, init?: RequestInit): Promise { + const url = apiUrl(path); + const response = await fetch(url, init); + if (!response.ok) throw new ApiError(response.status, response.statusText, url); + return response.json() as Promise; +} + +/** POST a JSON body and return the JSON payload; throws ApiError on non-2xx. */ +export async function apiPost(path: string, body: unknown, init?: RequestInit): Promise { + const url = apiUrl(path); + const response = await fetch(url, { + ...init, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(init?.headers as Record) }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new ApiError(response.status, response.statusText, url); + return response.json() as Promise; +} + +/** + * Fetch against the debug API (Cloudflare Access): sends cookies for the + * same-origin /api/* Worker route and attaches the X-Admin-Token header when + * a token is present. Returns the raw Response — the debug page inspects + * status codes itself. + */ +export function fetchWithAuth( + url: string, + token: string, + init: RequestInit = {} +): Promise { + const headers: Record = { ...((init.headers as Record) || {}) }; + if (token) headers['X-Admin-Token'] = token; + if (init.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; + return fetch(url, { credentials: 'include', ...init, headers }); +} diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx index 52f40342e7..2cfe54f2dc 100644 --- a/app/src/pages/DebugPage.tsx +++ b/app/src/pages/DebugPage.tsx @@ -13,6 +13,7 @@ import Typography from '@mui/material/Typography'; import { SectionHeader } from 'src/components/SectionHeader'; import { DEBUG_API_URL, LIB_ABBREV, LIB_TO_LANG, LIBRARIES } from 'src/constants'; import { useCopyCode } from 'src/hooks'; +import { fetchWithAuth } from 'src/lib/api'; import { colors, fontSize, semanticColors, typography } from 'src/theme'; import { buildClaudePrompt } from 'src/utils/claudePrompt'; import { specPath } from 'src/utils/paths'; @@ -236,13 +237,6 @@ const clearAdminToken = (): void => { } }; -const adminFetch = (url: string, token: string, init: RequestInit = {}): Promise => { - const headers: Record = { ...((init.headers as Record) || {}) }; - if (token) headers['X-Admin-Token'] = token; - if (init.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; - return fetch(url, { credentials: 'include', ...init, headers }); -}; - export function DebugPage() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -276,7 +270,7 @@ export function DebugPage() { } useEffect(() => { - adminFetch(`${DEBUG_API_URL}/debug/status`, adminToken) + fetchWithAuth(`${DEBUG_API_URL}/debug/status`, adminToken) .then(async r => { // Reaching here means the fetch promise resolved (the response may // still be 401/403/503 — those are handled below). Clear the one-shot @@ -345,7 +339,7 @@ export function DebugPage() { const tick = async () => { const started = performance.now(); try { - const r = await adminFetch(`${DEBUG_API_URL}/debug/ping`, adminToken); + const r = await fetchWithAuth(`${DEBUG_API_URL}/debug/ping`, adminToken); const totalMs = performance.now() - started; if (!r.ok) throw new Error(`${r.status}`); const json: { database_connected: boolean } = await r.json(); @@ -373,15 +367,15 @@ export function DebugPage() { let cancelled = false; const qs = feedbackStatusFilter ? `?status=${feedbackStatusFilter}&limit=50` : '?limit=50'; Promise.all([ - adminFetch( + fetchWithAuth( `${DEBUG_API_URL}/debug/feedback/top?reaction=thumbs_up&limit=15`, adminToken ).then(r => (r.ok ? (r.json() as Promise) : [])), - adminFetch( + fetchWithAuth( `${DEBUG_API_URL}/debug/feedback/top?reaction=thumbs_down&limit=15`, adminToken ).then(r => (r.ok ? (r.json() as Promise) : [])), - adminFetch(`${DEBUG_API_URL}/debug/feedback/messages${qs}`, adminToken).then(r => + fetchWithAuth(`${DEBUG_API_URL}/debug/feedback/messages${qs}`, adminToken).then(r => r.ok ? (r.json() as Promise) : [] ), ]) @@ -404,7 +398,7 @@ export function DebugPage() { const prev = feedbackMessages; setFeedbackMessages(prev.map(m => (m.id === id ? { ...m, status: newStatus } : m))); try { - const r = await adminFetch(`${DEBUG_API_URL}/debug/feedback/${id}`, adminToken, { + const r = await fetchWithAuth(`${DEBUG_API_URL}/debug/feedback/${id}`, adminToken, { method: 'PATCH', body: JSON.stringify({ status: newStatus }), }); diff --git a/app/src/pages/MapPage.tsx b/app/src/pages/MapPage.tsx index 659bbad950..48aeb32202 100644 --- a/app/src/pages/MapPage.tsx +++ b/app/src/pages/MapPage.tsx @@ -11,9 +11,9 @@ import Slider from '@mui/material/Slider'; import Typography from '@mui/material/Typography'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { API_URL } from 'src/constants'; import { useAnalytics } from 'src/hooks'; import { useTheme } from 'src/hooks/useLayoutContext'; +import { ApiError, apiGet, endpoints } from 'src/lib/api'; import { buildKNNLinks, buildVariantUrl, @@ -293,14 +293,14 @@ export function MapPage() { useEffect(() => { const ctrl = new AbortController(); - fetch(`${API_URL}/specs/map`, { signal: ctrl.signal }) - .then(r => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.json() as Promise; - }) + apiGet(endpoints.specsMap, { signal: ctrl.signal }) .then(setSpecs) - .catch(err => { - if (err.name !== 'AbortError') setError(err.message ?? 'Failed to load map data'); + .catch((err: unknown) => { + if (err instanceof Error && err.name === 'AbortError') return; + // Keep the pre-migration user-visible message ("HTTP ") rather + // than surfacing the longer ApiError format in the error banner. + if (err instanceof ApiError) setError(`HTTP ${err.status}`); + else setError(err instanceof Error ? err.message : 'Failed to load map data'); }); return () => ctrl.abort(); }, []); diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx index 0b93f0eb3d..e8cd6e06ab 100644 --- a/app/src/pages/SpecPage.tsx +++ b/app/src/pages/SpecPage.tsx @@ -11,9 +11,10 @@ import Typography from '@mui/material/Typography'; import { LibraryPills } from 'src/components/LibraryPills'; import { RelatedSpecs } from 'src/components/RelatedSpecs'; -import { API_URL, GITHUB_URL, LANG_DISPLAY } from 'src/constants'; +import { GITHUB_URL, LANG_DISPLAY } from 'src/constants'; import { useAnalytics, useCodeFetch } from 'src/hooks'; import { useAppData } from 'src/hooks'; +import { ApiError, apiGet, apiUrl, endpoints } from 'src/lib/api'; import { NotFoundPage } from 'src/pages/NotFoundPage'; import { colors, fontSize, semanticColors, typography } from 'src/theme'; import { specPath } from 'src/utils/paths'; @@ -94,13 +95,7 @@ export function SpecPage() { setHighlightedTags([]); try { - const res = await fetch(`${API_URL}/specs/${specId}`); - if (!res.ok) { - setError(res.status === 404 ? 'Spec not found' : 'Failed to load spec'); - return; - } - - const data: SpecDetail = await res.json(); + const data = await apiGet(endpoints.spec(specId)); setSpecData(data); // Detail mode: validate library matches an impl in the requested language. @@ -121,6 +116,10 @@ export function SpecPage() { } } } catch (err) { + if (err instanceof ApiError) { + setError(err.status === 404 ? 'Spec not found' : 'Failed to load spec'); + return; + } console.error('Error fetching spec:', err); setError('Failed to load spec'); } finally { @@ -252,7 +251,7 @@ export function SpecPage() { async (impl: Implementation) => { if (!specId) return; const link = document.createElement('a'); - link.href = `${API_URL}/download/${specId}/${impl.library_id}`; + link.href = apiUrl(endpoints.download(specId, impl.library_id)); link.download = `${specId}-${impl.library_id}.png`; document.body.appendChild(link); link.click(); diff --git a/app/src/pages/SpecsListPage.tsx b/app/src/pages/SpecsListPage.tsx index e18eeda38a..7e81c4a992 100644 --- a/app/src/pages/SpecsListPage.tsx +++ b/app/src/pages/SpecsListPage.tsx @@ -10,9 +10,10 @@ import Skeleton from '@mui/material/Skeleton'; import Typography from '@mui/material/Typography'; import { SectionHeader } from 'src/components/SectionHeader'; -import { API_URL, GITHUB_URL } from 'src/constants'; +import { GITHUB_URL } from 'src/constants'; import { useAnalytics } from 'src/hooks'; import { useAppData, useHomeState } from 'src/hooks'; +import { ApiError, apiGet, endpoints } from 'src/lib/api'; import { colors, fontSize, semanticColors, typography } from 'src/theme'; import type { PlotImage } from 'src/types'; import { specPath } from 'src/utils/paths'; @@ -55,17 +56,18 @@ export function SpecsListPage() { const fetchImages = async () => { try { - const res = await fetch(`${API_URL}/plots/filter`, { + const data = await apiGet<{ images?: PlotImage[] }>(endpoints.plotsFilter(), { signal: abortController.signal, }); if (abortController.signal.aborted) return; - if (res.ok) { - const data = await res.json(); - setAllImages(data.images || []); - } + setAllImages(data.images || []); } catch (err) { if (abortController.signal.aborted) return; - console.error('Error fetching images:', err); + // Non-2xx responses were silently ignored before the apiGet migration; + // keep logging only for network-level failures. + if (!(err instanceof ApiError)) { + console.error('Error fetching images:', err); + } } finally { if (!abortController.signal.aborted) { setLoading(false); diff --git a/app/src/pages/StatsPage.tsx b/app/src/pages/StatsPage.tsx index 7d2ea9f254..5da9c59ad0 100644 --- a/app/src/pages/StatsPage.tsx +++ b/app/src/pages/StatsPage.tsx @@ -9,9 +9,9 @@ import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { SectionHeader } from 'src/components/SectionHeader'; -import { API_URL } from 'src/constants'; import { useAnalytics } from 'src/hooks'; import { useTheme } from 'src/hooks/useLayoutContext'; +import { ApiError, apiGet, endpoints } from 'src/lib/api'; import { colors, fontSize, semanticColors, typography } from 'src/theme'; import { specPath } from 'src/utils/paths'; import { buildSrcSet, getFallbackSrc } from 'src/utils/responsiveImage'; @@ -112,22 +112,20 @@ export function StatsPage() { }, [trackPageview]); useEffect(() => { - fetch(`${API_URL}/insights/dashboard`) - .then(r => { - if (!r.ok) throw new Error(`${r.status}`); - return r.json(); - }) + apiGet(endpoints.insightsDashboard) .then(setData) - .catch(e => setError(e.message)) + // Keep the pre-ApiError user-visible string: a bare status code for + // HTTP failures, the raw message for network errors. + .catch(e => setError(e instanceof ApiError ? `${e.status}` : e.message)) .finally(() => setLoading(false)); }, []); // Visitors load separately so a Plausible outage / missing API key never - // blocks the rest of the dashboard from rendering. + // blocks the rest of the dashboard from rendering (non-2xx and network + // errors both fall through to the empty-points placeholder). useEffect(() => { - fetch(`${API_URL}/insights/visitors`) - .then(r => (r.ok ? r.json() : null)) - .then((res: VisitorsResponse | null) => setVisitors(res?.points ?? [])) + apiGet(endpoints.insightsVisitors) + .then(res => setVisitors(res?.points ?? [])) .catch(() => setVisitors([])); }, []);