Compare commits

..

2 Commits

Author SHA1 Message Date
df8ff4a058 Merge branch 'main' of ssh://gitapp.workolik.com:2222/Nearle_express/Express_developer_docs 2026-05-21 12:29:23 +05:30
ff8b5cc7d5 21052026 2026-05-21 12:29:19 +05:30
16 changed files with 125 additions and 1401 deletions

View File

@@ -6,18 +6,6 @@
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NearleXpress Developer Docs</title> <title>NearleXpress Developer Docs</title>
<script>
// Apply persisted theme before paint to avoid a flash of the wrong theme
(function () {
try {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
</script>
</head> </head>
<body> <body>

View File

@@ -10,12 +10,6 @@
status = 200 status = 200
force = true force = true
[[redirects]]
from = "/v1/*"
to = "https://api.workolik.com/v1/:splat"
status = 200
force = true
[[redirects]] [[redirects]]
from = "/*" from = "/*"
to = "/index.html" to = "/index.html"

View File

@@ -29,18 +29,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Proxy GraphQL v1 queries (e.g. /v1/graphql) to legacy backend
location /v1/ {
proxy_pass https://api.workolik.com/v1/;
proxy_set_header Host api.workolik.com;
proxy_ssl_server_name on;
# Optional: forward client IP
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_addrs;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Serve the React application # Serve the React application
location / { location / {
root /path/to/your/dist; root /path/to/your/dist;

8
package-lock.json generated
View File

@@ -67,6 +67,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1477,6 +1478,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001782",
@@ -2335,6 +2337,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -2691,6 +2694,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -2912,6 +2916,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3400,6 +3405,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3525,6 +3531,7 @@
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -3618,6 +3625,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },

View File

@@ -18,7 +18,7 @@ import 'dotenv/config'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const PORT = Number(process.env.PORT) || 3000 const PORT = Number(process.env.PORT) || 3000
const SECRET = (process.env.HASURA_ADMIN_SECRET || 'nearle-admin-secret').trim() const SECRET = (process.env.HASURA_ADMIN_SECRET || '').trim()
const TARGET_LEGACY = 'https://api.workolik.com' const TARGET_LEGACY = 'https://api.workolik.com'
const TARGET_REST = 'https://jupiter.nearle.app' const TARGET_REST = 'https://jupiter.nearle.app'
@@ -57,12 +57,6 @@ app.use('/live', createProxyMiddleware({
pathRewrite: (p) => '/live' + p pathRewrite: (p) => '/live' + p
})) }))
app.use('/v1', createProxyMiddleware({
...commonProxyOptions,
target: TARGET_LEGACY,
pathRewrite: (p) => '/v1' + p
}))
// Built React app // Built React app
const distDir = path.join(__dirname, 'dist') const distDir = path.join(__dirname, 'dist')
app.use(express.static(distDir)) app.use(express.static(distDir))
@@ -74,6 +68,5 @@ app.listen(PORT, () => {
console.log(`[xpress-docs] listening on http://localhost:${PORT}`) console.log(`[xpress-docs] listening on http://localhost:${PORT}`)
console.log(`[xpress-docs] proxying /api/* -> ${TARGET_LEGACY}/api/*`) console.log(`[xpress-docs] proxying /api/* -> ${TARGET_LEGACY}/api/*`)
console.log(`[xpress-docs] proxying /live/* -> ${TARGET_REST}/live/*`) console.log(`[xpress-docs] proxying /live/* -> ${TARGET_REST}/live/*`)
console.log(`[xpress-docs] proxying /v1/* -> ${TARGET_LEGACY}/v1/*`)
console.log(`[xpress-docs] admin secret: ${SECRET ? 'loaded' : 'NOT SET'}`) console.log(`[xpress-docs] admin secret: ${SECRET ? 'loaded' : 'NOT SET'}`)
}) })

View File

@@ -1,9 +1,7 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Menu, Layers } from 'lucide-react'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import Introduction from './components/Introduction' import Introduction from './components/Introduction'
import TopicView from './components/TopicView' import TopicView from './components/TopicView'
import ThemeToggle from './components/ThemeToggle'
import { legacyTopics, restTopics, LEGACY_BASE_URL, REST_BASE_URL } from './data/topics' import { legacyTopics, restTopics, LEGACY_BASE_URL, REST_BASE_URL } from './data/topics'
const processTopics = (topics, baseUrl, type) => const processTopics = (topics, baseUrl, type) =>
@@ -28,68 +26,31 @@ function filterTopics(topics, q) {
export default function App() { export default function App() {
const [activeTopic, setActiveTopic] = useState(null) const [activeTopic, setActiveTopic] = useState(null)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [sidebarOpen, setSidebarOpen] = useState(false)
const visibleLegacy = useMemo(() => filterTopics(allLegacy, searchQuery.trim().toLowerCase()), [searchQuery]) const visibleLegacy = useMemo(() => filterTopics(allLegacy, searchQuery.trim().toLowerCase()), [searchQuery])
const visibleRest = useMemo(() => filterTopics(allRest, searchQuery.trim().toLowerCase()), [searchQuery]) const visibleRest = useMemo(() => filterTopics(allRest, searchQuery.trim().toLowerCase()), [searchQuery])
// Picking a topic (or Introduction) should also close the drawer on mobile
const selectTopic = (t) => {
setActiveTopic(t)
setSidebarOpen(false)
}
return ( return (
<div className="min-h-screen bg-slate-50 dark:bg-dark-900 bg-grid-pattern flex relative overflow-hidden transition-colors"> <div className="min-h-screen bg-slate-50 bg-grid-pattern flex relative overflow-hidden">
{/* Ambient background glows */} {/* Ambient background glows */}
<div className="absolute top-0 -left-4 w-72 h-72 bg-brand-300 rounded-full mix-blend-multiply dark:mix-blend-screen filter blur-3xl opacity-20 dark:opacity-10 animate-blob"></div> <div className="absolute top-0 -left-4 w-72 h-72 bg-brand-300 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
<div className="absolute top-0 -right-4 w-72 h-72 bg-indigo-300 rounded-full mix-blend-multiply dark:mix-blend-screen filter blur-3xl opacity-20 dark:opacity-10 animate-blob animation-delay-2000"></div> <div className="absolute top-0 -right-4 w-72 h-72 bg-indigo-300 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
<div className="absolute -bottom-8 left-20 w-72 h-72 bg-pink-300 rounded-full mix-blend-multiply dark:mix-blend-screen filter blur-3xl opacity-20 dark:opacity-10 animate-blob animation-delay-4000"></div> <div className="absolute -bottom-8 left-20 w-72 h-72 bg-pink-300 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"></div>
{/* Theme toggle (floating, top-right) */}
<ThemeToggle />
{/* Mobile top bar */}
<header className="md:hidden fixed top-0 left-0 right-0 h-16 z-30 glass flex items-center gap-3 px-4">
<button
onClick={() => setSidebarOpen(true)}
aria-label="Open navigation"
className="w-10 h-10 flex items-center justify-center rounded-lg text-slate-600 dark:text-slate-300 hover:bg-slate-100/60 dark:hover:bg-white/5 transition-colors"
>
<Menu size={20} />
</button>
<button onClick={() => selectTopic(null)} className="flex items-center gap-2.5 text-slate-900 dark:text-slate-100 font-bold text-lg">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-brand-500 to-indigo-600 flex items-center justify-center shadow-glow">
<Layers className="text-white w-4 h-4" />
</div>
<span className="tracking-tight">NearleXpress</span>
</button>
</header>
{/* Mobile drawer backdrop */}
{sidebarOpen && (
<div
onClick={() => setSidebarOpen(false)}
className="md:hidden fixed inset-0 z-30 bg-slate-900/40 dark:bg-black/60 backdrop-blur-sm animate-fade-in"
/>
)}
<Sidebar <Sidebar
legacyTopics={visibleLegacy} legacyTopics={visibleLegacy}
restTopics={visibleRest} restTopics={visibleRest}
activeTopic={activeTopic} activeTopic={activeTopic}
setActiveTopic={selectTopic} setActiveTopic={setActiveTopic}
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={setSearchQuery}
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/> />
<main className="md:ml-[280px] flex-1 min-h-screen relative z-10 pt-16 md:pt-0"> <main className="ml-[280px] flex-1 min-h-screen relative z-10">
{activeTopic {activeTopic
? ( ? (
<div className="max-w-[1200px] mx-auto p-5 sm:p-8 lg:p-16 opacity-0 animate-fade-in-up"> <div className="max-w-[1200px] mx-auto p-12 lg:p-16 opacity-0 animate-fade-in-up">
<TopicView topic={activeTopic} searchQuery={searchQuery} /> <TopicView topic={activeTopic} searchQuery={searchQuery} />
</div> </div>
) )
@@ -98,7 +59,7 @@ export default function App() {
<Introduction <Introduction
allLegacy={allLegacy} allLegacy={allLegacy}
allRest={allRest} allRest={allRest}
setActiveTopic={selectTopic} setActiveTopic={setActiveTopic}
/> />
</div> </div>
)} )}

View File

@@ -1,9 +1,8 @@
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState } from 'react'
import { import {
Play, Copy, Check, CheckCircle2, AlertCircle, Server, FileJson, Loader2 Play, Copy, Check, CheckCircle2, AlertCircle, Server, FileJson, Loader2
} from 'lucide-react' } from 'lucide-react'
import { highlightJSON } from '../lib/highlight' import { highlightJSON } from '../lib/highlight'
import { normalizeAndGetMeta, getGraphQLKey } from '../data/graphqlMeta'
function safeDecode(v) { function safeDecode(v) {
try { return decodeURIComponent(v) } catch { return v } try { return decodeURIComponent(v) } catch { return v }
@@ -21,17 +20,8 @@ function parseUrl(url) {
return { path, params } return { path, params }
} }
export default function EndpointCard({ endpoint, baseUrl, isLegacy, onSend, result, loading }) { export default function EndpointCard({ endpoint, baseUrl, onSend, result, loading }) {
const gqlKey = useMemo(() => { const { path, params: parsedParams } = useMemo(() => parseUrl(endpoint.url), [endpoint.url])
return isLegacy ? getGraphQLKey(endpoint.name) : null
}, [endpoint.name, isLegacy])
const meta = useMemo(() => {
return gqlKey ? normalizeAndGetMeta(endpoint.name) : null
}, [endpoint.name, gqlKey])
const { path: originalPath, params: parsedParams } = useMemo(() => parseUrl(endpoint.url), [endpoint.url])
const path = isLegacy && gqlKey ? `/api/rest/${gqlKey}` : originalPath
const paramDefs = endpoint.params || parsedParams const paramDefs = endpoint.params || parsedParams
const [values, setValues] = useState(() => const [values, setValues] = useState(() =>
@@ -40,28 +30,6 @@ export default function EndpointCard({ endpoint, baseUrl, isLegacy, onSend, resu
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [urlCopied, setUrlCopied] = useState(false) const [urlCopied, setUrlCopied] = useState(false)
const [queryText, setQueryText] = useState(() => meta?.query || '')
const [variablesText, setVariablesText] = useState(() => meta?.variables || '{}')
// Reset and sync state when metadata changes
useEffect(() => {
setQueryText(meta?.query || '')
setVariablesText(meta?.variables || '{}')
if (meta?.variables) {
try {
const parsed = JSON.parse(meta.variables)
setValues(v => {
const next = { ...v }
Object.keys(next).forEach(k => {
if (parsed[k] !== undefined) next[k] = String(parsed[k])
})
return next
})
} catch (err) {}
}
}, [meta])
const composedUrl = useMemo(() => { const composedUrl = useMemo(() => {
if (paramDefs.length === 0) return baseUrl + path if (paramDefs.length === 0) return baseUrl + path
const qs = paramDefs const qs = paramDefs
@@ -70,30 +38,7 @@ export default function EndpointCard({ endpoint, baseUrl, isLegacy, onSend, resu
return `${baseUrl}${path}?${qs}` return `${baseUrl}${path}?${qs}`
}, [path, paramDefs, values, baseUrl]) }, [path, paramDefs, values, baseUrl])
const handleSend = () => { const handleSend = () => onSend(endpoint, composedUrl)
if (isLegacy && meta) {
const graphqlUrl = `${baseUrl}/v1/graphql`
let parsedVars = {}
try {
parsedVars = variablesText?.trim() ? JSON.parse(variablesText) : {}
} catch (err) {}
const overrideOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-hasura-admin-secret': 'nearle-admin-secret'
},
body: JSON.stringify({
query: queryText,
variables: parsedVars
})
}
onSend(endpoint, graphqlUrl, overrideOptions)
} else {
onSend(endpoint, composedUrl)
}
}
const copyResponse = async () => { const copyResponse = async () => {
if (!result || result.kind !== 'response') return if (!result || result.kind !== 'response') return
@@ -114,20 +59,20 @@ export default function EndpointCard({ endpoint, baseUrl, isLegacy, onSend, resu
} }
const methodColor = { const methodColor = {
GET: 'bg-emerald-100/50 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20', GET: 'bg-emerald-100/50 text-emerald-700 border-emerald-200',
POST: 'bg-blue-100/50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-500/20', POST: 'bg-blue-100/50 text-blue-700 border-blue-200',
PUT: 'bg-amber-100/50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-200 dark:border-amber-500/20', PUT: 'bg-amber-100/50 text-amber-700 border-amber-200',
DELETE: 'bg-red-100/50 dark:bg-red-500/10 text-red-700 dark:text-red-400 border-red-200 dark:border-red-500/20', DELETE: 'bg-red-100/50 text-red-700 border-red-200',
PATCH: 'bg-purple-100/50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-200 dark:border-purple-500/20', PATCH: 'bg-purple-100/50 text-purple-700 border-purple-200',
}[endpoint.method] || 'bg-slate-100/50 dark:bg-slate-500/10 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-500/20' }[endpoint.method] || 'bg-slate-100/50 text-slate-700 border-slate-200'
const isOk = result?.kind === 'response' && result.ok const isOk = result?.kind === 'response' && result.ok
const isErr = result?.kind === 'response' && !result.ok const isErr = result?.kind === 'response' && !result.ok
const isNet = result?.kind === 'network-error' const isNet = result?.kind === 'network-error'
return ( return (
<div className="py-10 sm:py-16 border-t border-slate-200/60 dark:border-white/10 group/row" id={endpoint.name}> <div className="py-16 border-t border-slate-200/60 group/row" id={endpoint.name}>
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6 sm:gap-12 lg:gap-16"> <div className="grid grid-cols-1 xl:grid-cols-12 gap-12 lg:gap-16">
{/* Left Column: Description & Parameters */} {/* Left Column: Description & Parameters */}
<div className="xl:col-span-5 space-y-8"> <div className="xl:col-span-5 space-y-8">
@@ -136,52 +81,44 @@ export default function EndpointCard({ endpoint, baseUrl, isLegacy, onSend, resu
<span className={`px-2.5 py-1 rounded text-xs font-bold tracking-widest border ${methodColor}`}> <span className={`px-2.5 py-1 rounded text-xs font-bold tracking-widest border ${methodColor}`}>
{endpoint.method} {endpoint.method}
</span> </span>
<h3 className="text-xl sm:text-2xl font-bold text-slate-900 dark:text-slate-100 tracking-tight break-words"> <h3 className="text-2xl font-bold text-slate-900 tracking-tight">
{endpoint.name} {endpoint.name}
</h3> </h3>
</div> </div>
{endpoint.description && ( {endpoint.description && (
<p className="text-[15px] text-slate-600 dark:text-slate-400 leading-relaxed">{endpoint.description}</p> <p className="text-[15px] text-slate-600 leading-relaxed">{endpoint.description}</p>
)} )}
</div> </div>
{/* URL bar */} {/* URL bar */}
<div className="bg-white dark:bg-dark-800 border border-slate-200/80 dark:border-white/10 rounded-xl p-3.5 font-mono text-sm shadow-sm flex items-start gap-2 group-hover/row:border-brand-300 dark:group-hover/row:border-brand-500/40 transition-colors min-w-0"> <div className="bg-white border border-slate-200/80 rounded-xl p-3.5 font-mono text-sm shadow-sm flex items-start gap-2 group-hover/row:border-brand-300 transition-colors min-w-0">
<Server size={14} className="text-brand-400 shrink-0 mt-0.5" /> <Server size={14} className="text-brand-400 shrink-0 mt-0.5" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-xs text-slate-400 dark:text-slate-500 truncate">{baseUrl}</div> <div className="text-xs text-slate-400 truncate">{baseUrl}</div>
<div className="font-semibold text-slate-800 dark:text-slate-200 break-all">{path}</div> <div className="font-semibold text-slate-800 break-all">{path}</div>
</div> </div>
</div> </div>
{/* Query Parameters */} {/* Query Parameters */}
{paramDefs.length > 0 && ( {paramDefs.length > 0 && (
<div className="pt-2"> <div className="pt-2">
<h4 className="text-xs font-bold text-slate-400 dark:text-slate-500 mb-4 uppercase tracking-widest flex items-center gap-2"> <h4 className="text-xs font-bold text-slate-400 mb-4 uppercase tracking-widest flex items-center gap-2">
<span className="w-4 h-[1px] bg-slate-300 dark:bg-white/10"></span> Query Parameters <span className="w-4 h-[1px] bg-slate-300"></span> Query Parameters
</h4> </h4>
<div className="space-y-4"> <div className="space-y-4">
{paramDefs.map((p) => ( {paramDefs.map((p) => (
<div key={p.name} className="flex flex-col gap-2"> <div key={p.name} className="flex flex-col gap-2">
<label className="text-sm font-semibold text-slate-800 dark:text-slate-200 flex justify-between items-center"> <label className="text-sm font-semibold text-slate-800 flex justify-between items-center">
{p.name} {p.name}
<span className="text-[10px] text-slate-400 dark:text-slate-500 uppercase tracking-wider font-mono">{p.type || 'string'}</span> <span className="text-[10px] text-slate-400 uppercase tracking-wider font-mono">{p.type || 'string'}</span>
</label> </label>
<input <input
className="w-full px-3.5 py-2.5 bg-white dark:bg-dark-800 border border-slate-200 dark:border-white/10 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 transition-all shadow-sm text-slate-700 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500" className="w-full px-3.5 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 transition-all shadow-sm text-slate-700"
value={values[p.name] ?? ''} value={values[p.name] ?? ''}
placeholder={`Enter ${p.name}...`} placeholder={`Enter ${p.name}...`}
onChange={(e) => { onChange={(e) =>
const newVal = e.target.value setValues((v) => ({ ...v, [p.name]: e.target.value }))
setValues((v) => ({ ...v, [p.name]: newVal })) }
if (isLegacy && meta) {
try {
const parsed = JSON.parse(variablesText || '{}')
const updatedValue = isNaN(newVal) || newVal.trim() === '' ? newVal : Number(newVal)
setVariablesText(JSON.stringify({ ...parsed, [p.name]: updatedValue }, null, 2))
} catch (err) {}
}
}}
/> />
</div> </div>
))} ))}
@@ -203,43 +140,7 @@ export default function EndpointCard({ endpoint, baseUrl, isLegacy, onSend, resu
</div> </div>
{/* Payload body or composed URL preview */} {/* Payload body or composed URL preview */}
{isLegacy && meta ? ( {endpoint.body ? (
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-white/5 min-h-[300px]">
<div className="p-5 flex flex-col group/pane relative">
<div className="text-[11px] font-bold text-slate-400 mb-3 uppercase tracking-wider flex items-center gap-2">
<FileJson size={14} className="text-brand-400" /> GraphQL Query
</div>
<textarea
className="flex-1 w-full bg-transparent text-slate-300 font-mono text-[13px] focus:outline-none resize-none leading-relaxed selection:bg-brand-500/30 scrollbar-hide min-h-[250px]"
value={queryText}
onChange={e => setQueryText(e.target.value)}
spellCheck="false"
/>
</div>
<div className="p-5 flex flex-col bg-white/[0.02]">
<div className="text-[11px] font-bold text-slate-400 mb-3 uppercase tracking-wider">Variables JSON</div>
<textarea
className="flex-1 w-full bg-transparent text-emerald-400 font-mono text-[13px] focus:outline-none resize-none leading-relaxed selection:bg-emerald-500/30 scrollbar-hide min-h-[250px]"
value={variablesText}
onChange={e => {
const txt = e.target.value
setVariablesText(txt)
try {
const parsed = JSON.parse(txt)
setValues(v => {
const next = { ...v }
Object.keys(next).forEach(k => {
if (parsed[k] !== undefined) next[k] = String(parsed[k])
})
return next
})
} catch (err) {}
}}
spellCheck="false"
/>
</div>
</div>
) : endpoint.body ? (
<div className="flex-1 p-5 flex flex-col"> <div className="flex-1 p-5 flex flex-col">
<div className="text-[11px] font-bold text-slate-400 mb-3 uppercase tracking-wider flex items-center gap-2"> <div className="text-[11px] font-bold text-slate-400 mb-3 uppercase tracking-wider flex items-center gap-2">
<FileJson size={14} className="text-brand-400" /> Request Body (JSON) <FileJson size={14} className="text-brand-400" /> Request Body (JSON)

View File

@@ -18,32 +18,32 @@ export default function Introduction({ allLegacy, allRest, setActiveTopic }) {
const allTopics = [...allLegacy, ...allRest] const allTopics = [...allLegacy, ...allRest]
return ( return (
<div className="max-w-[1000px] mx-auto px-5 py-12 sm:px-12 sm:py-20 lg:px-24 lg:py-28"> <div className="max-w-[1000px] mx-auto px-12 py-20 lg:px-24 lg:py-28">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-50 dark:bg-brand-500/10 border border-brand-100 dark:border-brand-500/20 text-brand-600 dark:text-brand-400 text-sm font-medium mb-8"> <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-50 border border-brand-100 text-brand-600 text-sm font-medium mb-8">
<Zap size={14} className="fill-current" /> <Zap size={14} className="fill-current" />
v1.0 Developer API v1.0 Developer API
</div> </div>
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-slate-900 dark:text-slate-100 mb-6 tracking-tight"> <h1 className="text-4xl lg:text-5xl font-bold text-slate-900 mb-6 tracking-tight">
<span className="text-gradient">NearleXpress API</span> <span className="text-gradient">NearleXpress API</span>
</h1> </h1>
<p className="text-lg sm:text-xl text-slate-600 dark:text-slate-400 mb-12 max-w-2xl leading-relaxed"> <p className="text-xl text-slate-600 mb-12 max-w-2xl leading-relaxed">
A comprehensive platform for managing tenants, users, partners, customers, A comprehensive platform for managing tenants, users, partners, customers,
orders, deliveries, products, invoices, and payments across the Express network. orders, deliveries, products, invoices, and payments across the Express network.
</p> </p>
<div className="mb-8"> <div className="mb-8">
<h2 className="text-xs font-bold uppercase tracking-widest text-slate-400 dark:text-slate-500 mb-4">Base URLs</h2> <h2 className="text-xs font-bold uppercase tracking-widest text-slate-400 mb-4">Base URLs</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-10"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-10">
<div className="bg-white dark:bg-dark-800 border border-slate-200/60 dark:border-white/10 rounded-xl p-4 shadow-sm"> <div className="bg-white border border-slate-200/60 rounded-xl p-4 shadow-sm">
<div className="text-[11px] text-brand-600 dark:text-brand-400 font-bold uppercase tracking-widest mb-1.5">GraphQL API</div> <div className="text-[11px] text-brand-600 font-bold uppercase tracking-widest mb-1.5">GraphQL API</div>
<code className="font-mono text-sm text-slate-700 dark:text-slate-300 break-all">{LEGACY_BASE_URL}</code> <code className="font-mono text-sm text-slate-700">{LEGACY_BASE_URL}</code>
</div> </div>
<div className="bg-white dark:bg-dark-800 border border-slate-200/60 dark:border-white/10 rounded-xl p-4 shadow-sm"> <div className="bg-white border border-slate-200/60 rounded-xl p-4 shadow-sm">
<div className="text-[11px] text-indigo-600 dark:text-indigo-400 font-bold uppercase tracking-widest mb-1.5">REST API</div> <div className="text-[11px] text-indigo-600 font-bold uppercase tracking-widest mb-1.5">REST API</div>
<code className="font-mono text-sm text-slate-700 dark:text-slate-300 break-all">{REST_BASE_URL}</code> <code className="font-mono text-sm text-slate-700">{REST_BASE_URL}</code>
</div> </div>
</div> </div>
</div> </div>
@@ -55,14 +55,14 @@ export default function Introduction({ allLegacy, allRest, setActiveTopic }) {
<div <div
key={topic.uniqueId} key={topic.uniqueId}
onClick={() => setActiveTopic(topic)} onClick={() => setActiveTopic(topic)}
className="group relative bg-white dark:bg-dark-800 p-6 rounded-2xl border border-slate-200/60 dark:border-white/10 shadow-sm hover:shadow-xl hover:shadow-brand-500/5 dark:hover:shadow-brand-500/10 hover:border-brand-200 dark:hover:border-brand-500/30 transition-all duration-300 cursor-pointer overflow-hidden" className="group relative bg-white p-6 rounded-2xl border border-slate-200/60 shadow-sm hover:shadow-xl hover:shadow-brand-500/5 hover:border-brand-200 transition-all duration-300 cursor-pointer overflow-hidden"
style={{ animationDelay: `${index * 100}ms` }} style={{ animationDelay: `${index * 100}ms` }}
> >
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-brand-50 dark:from-brand-500/10 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div> <div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-brand-50 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div className="relative z-10"> <div className="relative z-10">
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100 mb-2 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors flex items-center gap-2"> <h3 className="text-lg font-bold text-slate-900 mb-2 group-hover:text-brand-600 transition-colors flex items-center gap-2">
<div className="p-1.5 bg-brand-50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400 rounded-md group-hover:bg-brand-100 dark:group-hover:bg-brand-500/20 transition-colors"> <div className="p-1.5 bg-brand-50 text-brand-600 rounded-md group-hover:bg-brand-100 transition-colors">
<Icon size={18} /> <Icon size={18} />
</div> </div>
{topic.name} {topic.name}
@@ -71,14 +71,14 @@ export default function Introduction({ allLegacy, allRest, setActiveTopic }) {
<div className="flex items-center gap-2 mt-1 mb-2"> <div className="flex items-center gap-2 mt-1 mb-2">
<span className={`text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 rounded ${ <span className={`text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 rounded ${
topic.type === 'legacy' topic.type === 'legacy'
? 'bg-brand-50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400 border border-brand-100 dark:border-brand-500/20' ? 'bg-brand-50 text-brand-600 border border-brand-100'
: 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border border-indigo-100 dark:border-indigo-500/20' : 'bg-indigo-50 text-indigo-600 border border-indigo-100'
}`}> }`}>
{topic.type === 'legacy' ? 'GraphQL' : 'REST'} {topic.type === 'legacy' ? 'GraphQL' : 'REST'}
</span> </span>
<span className="text-[11px] text-slate-400 dark:text-slate-500">{topic.endpoints.length} endpoint{topic.endpoints.length !== 1 ? 's' : ''}</span> <span className="text-[11px] text-slate-400">{topic.endpoints.length} endpoint{topic.endpoints.length !== 1 ? 's' : ''}</span>
</div> </div>
<p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed"> <p className="text-sm text-slate-500 leading-relaxed">
{topic.description} {topic.description}
</p> </p>
</div> </div>
@@ -87,7 +87,7 @@ export default function Introduction({ allLegacy, allRest, setActiveTopic }) {
})} })}
</div> </div>
<div className="mt-16 flex items-center gap-4 p-6 bg-slate-900 dark:bg-dark-700 dark:border dark:border-white/10 text-slate-300 rounded-2xl shadow-code"> <div className="mt-16 flex items-center gap-4 p-6 bg-slate-900 text-slate-300 rounded-2xl shadow-code">
<Code2 className="text-brand-400 shrink-0" size={24} /> <Code2 className="text-brand-400 shrink-0" size={24} />
<p className="text-sm"> <p className="text-sm">
Explore interactive endpoints for both GraphQL and REST APIs. All requests route through the dev proxy which injects authentication headers securely. Explore interactive endpoints for both GraphQL and REST APIs. All requests route through the dev proxy which injects authentication headers securely.

View File

@@ -1,8 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { Search, ChevronRight, ChevronDown, Layers, Terminal, X } from 'lucide-react' import { Search, ChevronRight, ChevronDown, Layers, Terminal } from 'lucide-react'
import { getTopicIcon } from '../lib/icons' import { getTopicIcon } from '../lib/icons'
export default function Sidebar({ legacyTopics, restTopics, activeTopic, setActiveTopic, searchQuery, setSearchQuery, isOpen = false, onClose }) { export default function Sidebar({ legacyTopics, restTopics, activeTopic, setActiveTopic, searchQuery, setSearchQuery }) {
const [open, setOpen] = useState({ general: true, legacy: true, rest: true }) const [open, setOpen] = useState({ general: true, legacy: true, rest: true })
const toggle = (k) => setOpen((s) => ({ ...s, [k]: !s[k] })) const toggle = (k) => setOpen((s) => ({ ...s, [k]: !s[k] }))
@@ -10,12 +10,12 @@ export default function Sidebar({ legacyTopics, restTopics, activeTopic, setActi
<div> <div>
<button <button
onClick={() => toggle(key)} onClick={() => toggle(key)}
className="w-full flex items-center justify-between px-2 py-1.5 text-slate-800 dark:text-slate-200 font-semibold text-sm hover:text-brand-600 transition-colors group" className="w-full flex items-center justify-between px-2 py-1.5 text-slate-800 font-semibold text-sm hover:text-brand-600 transition-colors group"
> >
<span className="tracking-wide text-xs uppercase text-slate-400 dark:text-slate-500 group-hover:text-brand-500 transition-colors"> <span className="tracking-wide text-xs uppercase text-slate-400 group-hover:text-brand-500 transition-colors">
{title} {title}
</span> </span>
{open[key] ? <ChevronDown size={14} className="text-slate-400 dark:text-slate-500" /> : <ChevronRight size={14} className="text-slate-400 dark:text-slate-500" />} {open[key] ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
</button> </button>
<div className={`mt-2 space-y-1 overflow-hidden transition-all duration-500 ${open[key] ? 'max-h-[800px] opacity-100' : 'max-h-0 opacity-0'}`}> <div className={`mt-2 space-y-1 overflow-hidden transition-all duration-500 ${open[key] ? 'max-h-[800px] opacity-100' : 'max-h-0 opacity-0'}`}>
{topics.map((t) => { {topics.map((t) => {
@@ -27,13 +27,13 @@ export default function Sidebar({ legacyTopics, restTopics, activeTopic, setActi
onClick={() => setActiveTopic(t)} onClick={() => setActiveTopic(t)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all duration-200 group ${ className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all duration-200 group ${
isActive isActive
? 'text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-500/10 shadow-sm font-medium' ? 'text-brand-700 bg-brand-50 shadow-sm font-medium'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100/50 dark:hover:bg-white/5' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100/50'
}`} }`}
> >
<Icon <Icon
size={16} size={16}
className={`${isActive ? 'text-brand-500' : 'text-slate-400 dark:text-slate-500 group-hover:text-slate-500 dark:group-hover:text-slate-400'} transition-colors`} className={`${isActive ? 'text-brand-500' : 'text-slate-400 group-hover:text-slate-500'} transition-colors`}
/> />
{t.name} {t.name}
</button> </button>
@@ -44,35 +44,21 @@ export default function Sidebar({ legacyTopics, restTopics, activeTopic, setActi
) )
return ( return (
<div <div className="w-[280px] glass h-screen fixed top-0 left-0 flex flex-col z-20 transition-all">
className={`w-[280px] glass h-screen fixed top-0 left-0 flex flex-col z-40 transition-transform duration-300 ease-out ${
isOpen ? 'translate-x-0' : '-translate-x-full'
} md:translate-x-0`}
>
{/* Brand Header */} {/* Brand Header */}
<div className="p-6 border-b border-slate-100/50 dark:border-white/5"> <div className="p-6 border-b border-slate-100/50">
<div className="flex items-center justify-between gap-3 text-slate-900 dark:text-slate-100 font-bold text-xl mb-6"> <div className="flex items-center gap-3 text-slate-900 font-bold text-xl mb-6">
<div className="flex items-center gap-3"> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-indigo-600 flex items-center justify-center shadow-glow">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-indigo-600 flex items-center justify-center shadow-glow"> <Layers className="text-white w-4 h-4" />
<Layers className="text-white w-4 h-4" />
</div>
<span className="tracking-tight">NearleXpress</span>
</div> </div>
{/* Close button — mobile only */} <span className="tracking-tight">NearleXpress</span>
<button
onClick={onClose}
aria-label="Close navigation"
className="md:hidden w-8 h-8 -mr-1 flex items-center justify-center rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100/60 dark:hover:bg-white/5 transition-colors"
>
<X size={18} />
</button>
</div> </div>
<div className="relative group"> <div className="relative group">
<Search className="w-4 h-4 absolute left-3 top-3 text-slate-400 group-focus-within:text-brand-500 transition-colors" /> <Search className="w-4 h-4 absolute left-3 top-3 text-slate-400 group-focus-within:text-brand-500 transition-colors" />
<input <input
type="text" type="text"
className="w-full pl-9 pr-3 py-2.5 bg-slate-50/50 dark:bg-white/5 border border-slate-200/60 dark:border-white/10 rounded-xl text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 focus:bg-white dark:focus:bg-white/5 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-500" className="w-full pl-9 pr-3 py-2.5 bg-slate-50/50 border border-slate-200/60 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 focus:bg-white transition-all placeholder:text-slate-400"
placeholder="Search documentation..." placeholder="Search documentation..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
@@ -87,21 +73,21 @@ export default function Sidebar({ legacyTopics, restTopics, activeTopic, setActi
<div> <div>
<button <button
onClick={() => toggle('general')} onClick={() => toggle('general')}
className="w-full flex items-center justify-between px-2 py-1.5 text-slate-800 dark:text-slate-200 font-semibold text-sm hover:text-brand-600 transition-colors group" className="w-full flex items-center justify-between px-2 py-1.5 text-slate-800 font-semibold text-sm hover:text-brand-600 transition-colors group"
> >
<span className="tracking-wide text-xs uppercase text-slate-400 dark:text-slate-500 group-hover:text-brand-500 transition-colors">Getting Started</span> <span className="tracking-wide text-xs uppercase text-slate-400 group-hover:text-brand-500 transition-colors">Getting Started</span>
{open.general ? <ChevronDown size={14} className="text-slate-400 dark:text-slate-500" /> : <ChevronRight size={14} className="text-slate-400 dark:text-slate-500" />} {open.general ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
</button> </button>
<div className={`mt-2 space-y-1 overflow-hidden transition-all duration-300 ${open.general ? 'max-h-40 opacity-100' : 'max-h-0 opacity-0'}`}> <div className={`mt-2 space-y-1 overflow-hidden transition-all duration-300 ${open.general ? 'max-h-40 opacity-100' : 'max-h-0 opacity-0'}`}>
<button <button
onClick={() => setActiveTopic(null)} onClick={() => setActiveTopic(null)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${ className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
activeTopic === null activeTopic === null
? 'text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-500/10 shadow-sm font-medium' ? 'text-brand-700 bg-brand-50 shadow-sm font-medium'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100/50 dark:hover:bg-white/5' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100/50'
}`} }`}
> >
<Terminal size={16} className={activeTopic === null ? 'text-brand-500' : 'text-slate-400 dark:text-slate-500'} /> <Terminal size={16} className={activeTopic === null ? 'text-brand-500' : 'text-slate-400'} />
Introduction Introduction
</button> </button>
</div> </div>

View File

@@ -1,40 +0,0 @@
import { useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
function getInitialTheme() {
if (typeof window === 'undefined') return false
const stored = localStorage.getItem('theme')
if (stored) return stored === 'dark'
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
export default function ThemeToggle() {
const [dark, setDark] = useState(getInitialTheme)
useEffect(() => {
const root = document.documentElement
if (dark) root.classList.add('dark')
else root.classList.remove('dark')
try { localStorage.setItem('theme', dark ? 'dark' : 'light') } catch {}
}, [dark])
return (
<button
onClick={() => setDark((d) => !d)}
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
className="fixed top-3 right-3 sm:top-4 sm:right-4 z-50 w-10 h-10 sm:w-11 sm:h-11 rounded-full flex items-center justify-center glass text-slate-600 dark:text-slate-200 shadow-lg hover:scale-105 hover:text-brand-500 dark:hover:text-brand-400 active:scale-95 transition-all"
>
<span className="relative w-[18px] h-[18px]">
<Sun
size={18}
className={`absolute inset-0 transition-all duration-300 ${dark ? 'opacity-100 rotate-0 scale-100' : 'opacity-0 -rotate-90 scale-50'}`}
/>
<Moon
size={18}
className={`absolute inset-0 transition-all duration-300 ${dark ? 'opacity-0 rotate-90 scale-50' : 'opacity-100 rotate-0 scale-100'}`}
/>
</span>
</button>
)
}

View File

@@ -7,7 +7,14 @@ import { LEGACY_BASE_URL, REST_BASE_URL } from '../data/topics'
const ADMIN_SECRET = 'nearle-admin-secret' const ADMIN_SECRET = 'nearle-admin-secret'
function toProxyPath(fullUrl) { function toProxyPath(fullUrl) {
// We can now fetch from the full URL directly since CORS is enabled on the backend! // Legacy (api.workolik.com): We still proxy this to inject the admin secret via server
if (fullUrl.startsWith(LEGACY_BASE_URL)) {
return fullUrl.slice(LEGACY_BASE_URL.length)
}
// REST API (jupiter.nearle.app): We used to proxy this to avoid CORS issues,
// but we fixed CORS on the Kubernetes backend yesterday (jupiter-cors-proxy)!
// So we can now fetch from the full URL directly.
return fullUrl return fullUrl
} }
@@ -33,7 +40,7 @@ export default function TopicView({ topic, searchQuery }) {
} }
}, [topic.id]) }, [topic.id])
const handleSend = async (endpoint, composedUrl, overrideOptions = null) => { const handleSend = async (endpoint, composedUrl) => {
if (abortRef.current) abortRef.current.abort() if (abortRef.current) abortRef.current.abort()
const controller = new AbortController() const controller = new AbortController()
abortRef.current = controller abortRef.current = controller
@@ -42,46 +49,34 @@ export default function TopicView({ topic, searchQuery }) {
const start = Date.now() const start = Date.now()
try { try {
let fetchUrl = composedUrl const headers = {}
let fetchOptions = { if (endpoint.method !== 'GET') {
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 signal: controller.signal
} }
if (overrideOptions) { if (['POST', 'PUT', 'PATCH'].includes(endpoint.method) && endpoint.body) {
fetchOptions = { if (typeof endpoint.body === 'string') {
...fetchOptions, try {
...overrideOptions, // Re-stringify in case it's a badly formatted JSON string, but mostly it's just raw string
headers: { fetchOptions.body = JSON.stringify(JSON.parse(endpoint.body))
...overrideOptions.headers } catch {
} fetchOptions.body = endpoint.body
}
} else {
const headers = {}
if (endpoint.method !== 'GET') {
headers['Content-Type'] = 'application/json'
}
if (topic.type === 'legacy') {
headers['x-hasura-admin-secret'] = ADMIN_SECRET
}
fetchOptions.method = endpoint.method
fetchOptions.headers = headers
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)
} }
} else {
fetchOptions.body = JSON.stringify(endpoint.body)
} }
} }
const res = await fetch(toProxyPath(fetchUrl), fetchOptions) const res = await fetch(toProxyPath(composedUrl), fetchOptions)
const ms = Date.now() - start const ms = Date.now() - start
const text = await res.text() const text = await res.text()
let body let body
@@ -108,24 +103,24 @@ export default function TopicView({ topic, searchQuery }) {
return ( return (
<div className="pb-24"> <div className="pb-24">
<div className="mb-12"> <div className="mb-12">
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-slate-900 dark:text-slate-100 mb-4 tracking-tight">{topic.name}</h1> <h1 className="text-4xl lg:text-5xl font-bold text-slate-900 mb-4 tracking-tight">{topic.name}</h1>
<p className="text-base sm:text-lg text-slate-600 dark:text-slate-400 max-w-3xl leading-relaxed">{topic.description}</p> <p className="text-lg text-slate-600 max-w-3xl leading-relaxed">{topic.description}</p>
<div className="mt-4 flex items-center gap-3"> <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 ${ <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' topic.type === 'legacy'
? 'bg-brand-50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400 border-brand-100 dark:border-brand-500/20' ? 'bg-brand-50 text-brand-600 border-brand-100'
: 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-indigo-100 dark:border-indigo-500/20' : 'bg-indigo-50 text-indigo-600 border-indigo-100'
}`}> }`}>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span> <span className="w-1.5 h-1.5 rounded-full bg-current"></span>
{topic.type === 'legacy' ? 'GraphQL API' : 'REST API'} {topic.type === 'legacy' ? 'GraphQL API' : 'REST API'}
</span> </span>
<span className="text-sm text-slate-400 dark:text-slate-500">{filtered.length} endpoint{filtered.length !== 1 ? 's' : ''}</span> <span className="text-sm text-slate-400">{filtered.length} endpoint{filtered.length !== 1 ? 's' : ''}</span>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="text-center py-20 text-slate-400 dark:text-slate-500 text-sm bg-white dark:bg-dark-800 rounded-2xl border border-slate-200/60 dark:border-white/10"> <div className="text-center py-20 text-slate-400 text-sm bg-white rounded-2xl border border-slate-200/60">
No endpoints match "{searchQuery}". No endpoints match "{searchQuery}".
</div> </div>
) : ( ) : (
@@ -136,7 +131,6 @@ export default function TopicView({ topic, searchQuery }) {
key={`${topic.uniqueId}/${e.name}`} key={`${topic.uniqueId}/${e.name}`}
endpoint={e} endpoint={e}
baseUrl={topic.baseUrl} baseUrl={topic.baseUrl}
isLegacy={topic.type === 'legacy'}
onSend={handleSend} onSend={handleSend}
result={isActive ? active.result : null} result={isActive ? active.result : null}
loading={isActive ? active.loading : false} loading={isActive ? active.loading : false}

File diff suppressed because it is too large Load Diff

View File

@@ -8,18 +8,6 @@
body { body {
@apply bg-slate-50 text-slate-800 font-sans antialiased selection:bg-brand-500/30 selection:text-brand-900; @apply bg-slate-50 text-slate-800 font-sans antialiased selection:bg-brand-500/30 selection:text-brand-900;
} }
.dark body {
@apply bg-dark-900 text-slate-200;
}
html {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
} }
@layer utilities { @layer utilities {
@@ -27,10 +15,6 @@
@apply bg-white/70 backdrop-blur-md border border-white/40 shadow-glass; @apply bg-white/70 backdrop-blur-md border border-white/40 shadow-glass;
} }
.dark .glass {
@apply bg-dark-800/80 border-white/5 shadow-none;
}
.dark-glass { .dark-glass {
@apply bg-dark-900/90 backdrop-blur-xl border border-white/5; @apply bg-dark-900/90 backdrop-blur-xl border border-white/5;
} }
@@ -56,22 +40,12 @@
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
@apply bg-slate-300; @apply bg-slate-300;
} }
.dark ::-webkit-scrollbar-thumb {
@apply bg-dark-600 rounded-full;
}
.dark ::-webkit-scrollbar-thumb:hover {
@apply bg-dark-700;
}
.bg-grid-pattern { .bg-grid-pattern {
background-image: linear-gradient(to right, #f1f5f9 1px, transparent 1px), background-image: linear-gradient(to right, #f1f5f9 1px, transparent 1px),
linear-gradient(to bottom, #f1f5f9 1px, transparent 1px); linear-gradient(to bottom, #f1f5f9 1px, transparent 1px);
background-size: 40px 40px; background-size: 40px 40px;
} }
.dark .bg-grid-pattern {
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
}
.animation-delay-2000 { animation-delay: 2s; } .animation-delay-2000 { animation-delay: 2s; }
.animation-delay-4000 { animation-delay: 4s; } .animation-delay-4000 { animation-delay: 4s; }

View File

@@ -1,6 +1,5 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{js,jsx}'], content: ['./index.html', './src/**/*.{js,jsx}'],
theme: { theme: {
extend: { extend: {

View File

@@ -8,10 +8,6 @@
"source": "/api/:path*", "source": "/api/:path*",
"destination": "https://api.workolik.com/api/:path*" "destination": "https://api.workolik.com/api/:path*"
}, },
{
"source": "/v1/:path*",
"destination": "https://api.workolik.com/v1/:path*"
},
{ {
"source": "/(.*)", "source": "/(.*)",
"destination": "/index.html" "destination": "/index.html"

View File

@@ -3,11 +3,11 @@ import react from '@vitejs/plugin-react'
// Dev mode: proxy /api/* to api.workolik.com and inject x-hasura-admin-secret // Dev mode: proxy /api/* to api.workolik.com and inject x-hasura-admin-secret
// server-side so the secret never reaches the browser. // server-side so the secret never reaches the browser.
export default defineConfig(({ mode }) => { export default defineConfig(({ command, mode }) => {
const env = loadEnv(mode, process.cwd(), '') const env = loadEnv(mode, process.cwd(), '')
const secret = (env.HASURA_ADMIN_SECRET || 'nearle-admin-secret').trim() const secret = (env.HASURA_ADMIN_SECRET || '').trim()
if (!secret) { if (!secret && command === 'serve') {
console.warn('[xpress-docs] HASURA_ADMIN_SECRET is not set in .env.local; proxied requests will hit the API without auth.') console.warn('[xpress-docs] HASURA_ADMIN_SECRET is not set in .env.local; proxied requests will hit the API without auth.')
} }
@@ -22,20 +22,10 @@ export default defineConfig(({ mode }) => {
}) })
} }
}, },
'/v1': {
target: 'https://api.workolik.com',
changeOrigin: true,
secure: true,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
if (secret) proxyReq.setHeader('x-hasura-admin-secret', secret)
})
}
},
'/live': { '/live': {
target: 'https://jupiter.nearle.app', target: env.VITE_LIVE_API_TARGET || 'https://jupiter.nearle.app',
changeOrigin: true, changeOrigin: true,
secure: true, secure: env.VITE_LIVE_API_TARGET ? false : true,
} }
} }