initial commit
This commit is contained in:
110
src/components/TopicView.jsx
Normal file
110
src/components/TopicView.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import EndpointCard from './EndpointCard'
|
||||
import { getTopicIcon } from '../lib/icons'
|
||||
import { BASE_URL } from '../data/topics'
|
||||
|
||||
// Strip the documented BASE_URL so fetches are same-origin and get
|
||||
// proxied by Vite (dev) or Express (prod), which injects the secret header.
|
||||
function toProxyPath(fullUrl) {
|
||||
if (fullUrl.startsWith(BASE_URL)) return fullUrl.slice(BASE_URL.length)
|
||||
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
|
||||
|
||||
// Only one endpoint's response is visible at a time. Sending on a new
|
||||
// endpoint replaces the previous one (and cancels its in-flight fetch).
|
||||
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 res = await fetch(toProxyPath(composedUrl), {
|
||||
method: endpoint.method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal
|
||||
})
|
||||
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="max-w-4xl">
|
||||
<div className="mb-8 flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500 to-indigo-600 flex items-center justify-center shadow-glow flex-shrink-0">
|
||||
<Icon className="text-white w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">{topic.name}</h1>
|
||||
<p className="text-slate-600 mt-2 leading-relaxed">{topic.description}</p>
|
||||
<div className="mt-3 text-xs text-slate-400 uppercase tracking-wide">
|
||||
{filtered.length} endpoint{filtered.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-16 text-slate-400 text-sm">
|
||||
No endpoints match "{searchQuery}".
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((e) => {
|
||||
const isActive = active?.name === e.name
|
||||
return (
|
||||
<EndpointCard
|
||||
key={`${topic.id}/${e.name}`}
|
||||
endpoint={e}
|
||||
onSend={handleSend}
|
||||
result={isActive ? active.result : null}
|
||||
loading={isActive ? active.loading : false}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user