135 lines
4.4 KiB
JavaScript
135 lines
4.4 KiB
JavaScript
import { useEffect, useRef, useState } from 'react'
|
|
import EndpointCard from './EndpointCard'
|
|
import { getTopicIcon } from '../lib/icons'
|
|
|
|
import { LEGACY_BASE_URL } from '../data/topics'
|
|
|
|
const ADMIN_SECRET = 'nearle-admin-secret'
|
|
|
|
function toProxyPath(fullUrl) {
|
|
// Always fetch the full URL directly.
|
|
// Legacy (api.workolik.com): CORS open, admin secret injected in headers.
|
|
// REST (jupiter.nearle.app): fetched directly, no proxy needed.
|
|
return fullUrl
|
|
}
|
|
|
|
export default function TopicView({ topic, searchQuery }) {
|
|
const Icon = getTopicIcon(topic.id)
|
|
const q = searchQuery.trim().toLowerCase()
|
|
const filtered = q
|
|
? topic.endpoints.filter((e) =>
|
|
e.name.toLowerCase().includes(q) ||
|
|
e.url.toLowerCase().includes(q) ||
|
|
(e.description || '').toLowerCase().includes(q)
|
|
)
|
|
: topic.endpoints
|
|
|
|
const [active, setActive] = useState(null)
|
|
const abortRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
setActive(null)
|
|
if (abortRef.current) {
|
|
abortRef.current.abort()
|
|
abortRef.current = null
|
|
}
|
|
}, [topic.id])
|
|
|
|
const handleSend = async (endpoint, composedUrl) => {
|
|
if (abortRef.current) abortRef.current.abort()
|
|
const controller = new AbortController()
|
|
abortRef.current = controller
|
|
|
|
setActive({ name: endpoint.name, result: null, loading: true })
|
|
const start = Date.now()
|
|
|
|
try {
|
|
const headers = { 'Content-Type': 'application/json' }
|
|
if (topic.type === 'legacy') headers['x-hasura-admin-secret'] = ADMIN_SECRET
|
|
|
|
const fetchOptions = {
|
|
method: endpoint.method,
|
|
headers,
|
|
signal: controller.signal
|
|
}
|
|
|
|
if (['POST', 'PUT', 'PATCH'].includes(endpoint.method) && endpoint.body) {
|
|
if (typeof endpoint.body === 'string') {
|
|
try {
|
|
// Re-stringify in case it's a badly formatted JSON string, but mostly it's just raw string
|
|
fetchOptions.body = JSON.stringify(JSON.parse(endpoint.body))
|
|
} catch {
|
|
fetchOptions.body = endpoint.body
|
|
}
|
|
} else {
|
|
fetchOptions.body = JSON.stringify(endpoint.body)
|
|
}
|
|
}
|
|
|
|
const res = await fetch(toProxyPath(composedUrl), fetchOptions)
|
|
const ms = Date.now() - start
|
|
const text = await res.text()
|
|
let body
|
|
try { body = JSON.parse(text) } catch { body = text }
|
|
setActive({
|
|
name: endpoint.name,
|
|
result: { kind: 'response', status: res.status, ok: res.ok, body, ms },
|
|
loading: false
|
|
})
|
|
} catch (err) {
|
|
if (err?.name === 'AbortError') return
|
|
setActive({
|
|
name: endpoint.name,
|
|
result: {
|
|
kind: 'network-error',
|
|
message: err?.message || String(err),
|
|
ms: Date.now() - start
|
|
},
|
|
loading: false
|
|
})
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="pb-24">
|
|
<div className="mb-12">
|
|
<h1 className="text-4xl lg:text-5xl font-bold text-slate-900 mb-4 tracking-tight">{topic.name}</h1>
|
|
<p className="text-lg text-slate-600 max-w-3xl leading-relaxed">{topic.description}</p>
|
|
<div className="mt-4 flex items-center gap-3">
|
|
<span className={`inline-flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-widest px-3 py-1 rounded-full border ${
|
|
topic.type === 'legacy'
|
|
? 'bg-brand-50 text-brand-600 border-brand-100'
|
|
: 'bg-indigo-50 text-indigo-600 border-indigo-100'
|
|
}`}>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
|
{topic.type === 'legacy' ? 'GraphQL API' : 'REST API'}
|
|
</span>
|
|
<span className="text-sm text-slate-400">{filtered.length} endpoint{filtered.length !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{filtered.length === 0 ? (
|
|
<div className="text-center py-20 text-slate-400 text-sm bg-white rounded-2xl border border-slate-200/60">
|
|
No endpoints match "{searchQuery}".
|
|
</div>
|
|
) : (
|
|
filtered.map((e) => {
|
|
const isActive = active?.name === e.name
|
|
return (
|
|
<EndpointCard
|
|
key={`${topic.uniqueId}/${e.name}`}
|
|
endpoint={e}
|
|
baseUrl={topic.baseUrl}
|
|
onSend={handleSend}
|
|
result={isActive ? active.result : null}
|
|
loading={isActive ? active.loading : false}
|
|
/>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|