Skip to content

Commit 9c1b837

Browse files
committed
feat(docs): add interactive API reference documentation page
Add comprehensive API documentation with interactive endpoint cards, organized by category (authentication, search, skills, user/keys, leaderboard/admin). Includes sub-navigation for docs section, complete endpoint specifications, and integration into main docs page. Changes: - New /docs/api route for interactive API reference - DocsSubNav component for docs section navigation - ApiEndpointCard component for rendering endpoint details - API endpoints data files (search, skills, user, admin) - Updated docs page with tab navigation - API reference documentation (api-reference.md)
1 parent f0e06b5 commit 9c1b837

8 files changed

Lines changed: 1224 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { Check, Copy } from "lucide-react";
2+
import { useState } from "react";
3+
4+
export interface EndpointParam {
5+
name: string;
6+
type: string;
7+
required: boolean;
8+
description: string;
9+
default?: string;
10+
}
11+
12+
export interface EndpointError {
13+
status: number;
14+
body: string;
15+
cause: string;
16+
}
17+
18+
export interface EndpointProps {
19+
id: string;
20+
method: "GET" | "POST" | "DELETE";
21+
path: string;
22+
auth: string;
23+
description: string;
24+
params?: EndpointParam[];
25+
requestExample?: string;
26+
responseExample: string;
27+
errors?: EndpointError[];
28+
notes?: string[];
29+
}
30+
31+
const METHOD_COLORS: Record<string, string> = {
32+
GET: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
33+
POST: "bg-blue-500/15 text-blue-400 border-blue-500/30",
34+
DELETE: "bg-red-500/15 text-red-400 border-red-500/30",
35+
};
36+
37+
export function ApiEndpointCard(props: EndpointProps) {
38+
const { id, method, path, auth, description, params, requestExample, responseExample, errors, notes } = props;
39+
40+
return (
41+
<div id={id} className="scroll-mt-20 rounded-lg border border-sx-border bg-sx-bg-elevated p-6">
42+
{/* Header: method badge + path */}
43+
<div className="mb-3 flex flex-wrap items-center gap-3">
44+
<span className={`rounded border px-2 py-0.5 font-mono text-xs font-bold ${METHOD_COLORS[method]}`}>
45+
{method}
46+
</span>
47+
<code className="font-mono text-sm text-sx-fg">{path}</code>
48+
<span className="ml-auto text-xs text-sx-fg-subtle">Auth: {auth}</span>
49+
</div>
50+
51+
<p className="mb-4 text-sm text-sx-fg-muted">{description}</p>
52+
53+
{/* Params table */}
54+
{params && params.length > 0 && (
55+
<div className="mb-4">
56+
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-sx-fg-subtle">Parameters</h4>
57+
<div className="overflow-x-auto">
58+
<table className="w-full text-sm">
59+
<thead>
60+
<tr className="border-b border-sx-border text-left text-xs text-sx-fg-subtle">
61+
<th className="pb-2 pr-4 font-medium">Name</th>
62+
<th className="pb-2 pr-4 font-medium">Type</th>
63+
<th className="pb-2 pr-4 font-medium">Required</th>
64+
<th className="pb-2 font-medium">Description</th>
65+
</tr>
66+
</thead>
67+
<tbody>
68+
{params.map((p) => (
69+
<tr key={p.name} className="border-b border-sx-border/50">
70+
<td className="py-2 pr-4 font-mono text-xs text-sx-accent">{p.name}</td>
71+
<td className="py-2 pr-4 text-sx-fg-muted">{p.type}</td>
72+
<td className="py-2 pr-4">{p.required ? <span className="text-sx-warning">Yes</span> : <span className="text-sx-fg-subtle">No{p.default ? ` (${p.default})` : ""}</span>}</td>
73+
<td className="py-2 text-sx-fg-muted">{p.description}</td>
74+
</tr>
75+
))}
76+
</tbody>
77+
</table>
78+
</div>
79+
</div>
80+
)}
81+
82+
{/* Request example */}
83+
{requestExample && (
84+
<div className="mb-4">
85+
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-sx-fg-subtle">Request Body</h4>
86+
<CodeBlock content={requestExample} />
87+
</div>
88+
)}
89+
90+
{/* Response example */}
91+
<div className="mb-4">
92+
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-sx-fg-subtle">Response</h4>
93+
<CodeBlock content={responseExample} />
94+
</div>
95+
96+
{/* Notes */}
97+
{notes && notes.length > 0 && (
98+
<div className="mb-4">
99+
<ul className="list-inside list-disc space-y-1 text-xs text-sx-fg-muted">
100+
{notes.map((note, i) => <li key={i}>{note}</li>)}
101+
</ul>
102+
</div>
103+
)}
104+
105+
{/* Errors */}
106+
{errors && errors.length > 0 && (
107+
<div>
108+
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-sx-fg-subtle">Errors</h4>
109+
<div className="space-y-1">
110+
{errors.map((e) => (
111+
<div key={`${e.status}-${e.cause}`} className="flex gap-3 text-xs">
112+
<span className="w-8 shrink-0 font-mono text-sx-error">{e.status}</span>
113+
<span className="text-sx-fg-muted">{e.cause}</span>
114+
</div>
115+
))}
116+
</div>
117+
</div>
118+
)}
119+
</div>
120+
);
121+
}
122+
123+
function CodeBlock({ content }: { content: string }) {
124+
const [copied, setCopied] = useState(false);
125+
126+
const handleCopy = async () => {
127+
try {
128+
await navigator.clipboard.writeText(content);
129+
setCopied(true);
130+
setTimeout(() => setCopied(false), 2000);
131+
} catch {
132+
// Clipboard API unavailable (non-HTTPS or permission denied)
133+
}
134+
};
135+
136+
return (
137+
<div className="group relative rounded-md border border-sx-border bg-sx-bg p-4">
138+
<button
139+
onClick={handleCopy}
140+
className="absolute right-2 top-2 rounded p-1 text-sx-fg-subtle opacity-0 transition-opacity hover:text-sx-fg group-hover:opacity-100"
141+
aria-label="Copy"
142+
>
143+
{copied ? <Check size={14} /> : <Copy size={14} />}
144+
</button>
145+
<pre className="overflow-x-auto font-mono text-xs leading-relaxed text-sx-fg-muted">
146+
{content}
147+
</pre>
148+
</div>
149+
);
150+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Link, useLocation } from "react-router";
2+
3+
const TABS = [
4+
{ to: "/docs", label: "CLI Guide" },
5+
{ to: "/docs/api", label: "API Reference" },
6+
] as const;
7+
8+
export function DocsSubNav() {
9+
const { pathname } = useLocation();
10+
11+
return (
12+
<nav aria-label="Documentation sections" className="mb-8 flex gap-1 rounded-lg border border-sx-border bg-sx-bg-elevated p-1">
13+
{TABS.map((tab) => {
14+
const active = pathname === tab.to;
15+
return (
16+
<Link
17+
key={tab.to}
18+
to={tab.to}
19+
className={`rounded-md px-4 py-2 font-mono text-sm transition-colors ${
20+
active
21+
? "bg-sx-accent-muted text-sx-accent"
22+
: "text-sx-fg-muted hover:text-sx-fg"
23+
}`}
24+
>
25+
{tab.label}
26+
</Link>
27+
);
28+
})}
29+
</nav>
30+
);
31+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import type { EndpointProps } from "~/components/docs/api-endpoint-card";
2+
3+
/** Search + Skill endpoint definitions for the API reference page. */
4+
5+
export const SEARCH_ENDPOINTS: EndpointProps[] = [
6+
{
7+
id: "search-get",
8+
method: "GET",
9+
path: "/api/search?q=<query>",
10+
auth: "Optional",
11+
description: "Search skills via hybrid keyword + semantic engine. Authenticated users get personalized favorite boost.",
12+
params: [
13+
{ name: "q", type: "string", required: true, description: "Search query" },
14+
{ name: "category", type: "string", required: false, description: "Filter by category" },
15+
{ name: "is_paid", type: "string", required: false, description: '"true" or "false"' },
16+
{ name: "limit", type: "number", required: false, description: "Max results (max 100)", default: "20" },
17+
],
18+
responseExample: `{
19+
"results": [
20+
{
21+
"id": "uuid",
22+
"name": "Skill Name",
23+
"slug": "skill-name",
24+
"description": "...",
25+
"category": "development",
26+
"avg_rating": 8.5,
27+
"install_count": 1200,
28+
"github_stars": 450,
29+
"final_score": 0.82,
30+
"rrf_score": 0.031,
31+
"semantic_rank": 2,
32+
"keyword_rank": 5
33+
}
34+
],
35+
"count": 1
36+
}`,
37+
notes: ['Returns empty results if no "q" param provided.'],
38+
errors: [
39+
{ status: 400, body: "Query parameter is required", cause: "Missing or non-string query" },
40+
{ status: 500, body: "Search failed", cause: "Internal error" },
41+
],
42+
},
43+
{
44+
id: "search-post",
45+
method: "POST",
46+
path: "/api/search",
47+
auth: "Optional",
48+
description: "Same search via JSON body. Preferred for API integrations.",
49+
params: [
50+
{ name: "query", type: "string", required: true, description: "Search query" },
51+
{ name: "category", type: "string", required: false, description: "Filter by category" },
52+
{ name: "is_paid", type: "boolean", required: false, description: "Filter free/paid" },
53+
{ name: "limit", type: "number", required: false, description: "Max results (max 100)", default: "20" },
54+
],
55+
requestExample: `{
56+
"query": "kubernetes deploy",
57+
"category": "devops",
58+
"is_paid": false,
59+
"limit": 20
60+
}`,
61+
responseExample: `Same as GET /api/search`,
62+
errors: [
63+
{ status: 400, body: "Query parameter is required", cause: "Missing or non-string query" },
64+
{ status: 500, body: "Search failed", cause: "Internal error" },
65+
],
66+
},
67+
];
68+
69+
export const SKILL_ENDPOINTS: EndpointProps[] = [
70+
{
71+
id: "skill-detail",
72+
method: "GET",
73+
path: "/api/skills/:slug",
74+
auth: "Optional",
75+
description: "Fetch full skill details with reviews and rating summary. Authenticated users see isFavorited status.",
76+
responseExample: `{
77+
"skill": { "id": "uuid", "name": "...", "slug": "...", "content": "...", ... },
78+
"reviews": [{ "id": "...", "content": "...", "created_at": 1707955200000 }],
79+
"isFavorited": false,
80+
"ratingSummary": { "avgRating": 8.5, "ratingCount": 42 }
81+
}`,
82+
errors: [
83+
{ status: 400, body: "Skill slug is required", cause: "Missing slug param" },
84+
{ status: 404, body: "Skill not found", cause: "Invalid slug" },
85+
],
86+
},
87+
{
88+
id: "skill-rate",
89+
method: "POST",
90+
path: "/api/skills/:slug/rate",
91+
auth: "Session required",
92+
description: "Rate a skill (0-10 scale). Upserts — re-rating updates existing score.",
93+
params: [
94+
{ name: "score", type: "number", required: true, description: "Rating 0-10 inclusive" },
95+
],
96+
requestExample: `{ "score": 8.5 }`,
97+
responseExample: `{ "success": true, "avg_rating": 8.2, "rating_count": 43 }`,
98+
errors: [
99+
{ status: 400, body: "Score must be a number between 0 and 10", cause: "Invalid score value" },
100+
{ status: 401, body: "Authentication required", cause: "Not logged in" },
101+
{ status: 404, body: "Skill not found", cause: "Invalid slug" },
102+
],
103+
},
104+
{
105+
id: "skill-review-get",
106+
method: "GET",
107+
path: "/api/skills/:slug/review",
108+
auth: "None",
109+
description: "List reviews for a skill, newest first (max 100).",
110+
responseExample: `{
111+
"reviews": [
112+
{
113+
"id": "review-...",
114+
"content": "Excellent skill for Kubernetes deployment",
115+
"is_agent": false,
116+
"created_at": 1707955200000
117+
}
118+
]
119+
}`,
120+
errors: [{ status: 404, body: "Skill not found", cause: "Invalid slug" }],
121+
},
122+
{
123+
id: "skill-review-post",
124+
method: "POST",
125+
path: "/api/skills/:slug/review",
126+
auth: "Session required",
127+
description: "Submit a text review for a skill.",
128+
params: [
129+
{ name: "content", type: "string", required: true, description: "1-2000 characters" },
130+
],
131+
requestExample: `{ "content": "This skill saved me hours of work!" }`,
132+
responseExample: `{ "success": true, "review": { "id": "review-...", "content": "...", ... } }`,
133+
errors: [
134+
{ status: 400, body: "Review content cannot be empty", cause: "Empty content" },
135+
{ status: 400, body: "Cannot exceed 2000 characters", cause: "Too long" },
136+
{ status: 401, body: "Authentication required", cause: "Not logged in" },
137+
{ status: 404, body: "Skill not found", cause: "Invalid slug" },
138+
],
139+
},
140+
{
141+
id: "skill-favorite",
142+
method: "POST",
143+
path: "/api/skills/:slug/favorite",
144+
auth: "Session required",
145+
description: "Toggle favorite status. Adds if not favorited, removes if already favorited.",
146+
responseExample: `{ "favorited": true }`,
147+
notes: ["favorited: true = added, false = removed. No request body needed."],
148+
errors: [
149+
{ status: 401, body: "Authentication required", cause: "Not logged in" },
150+
{ status: 404, body: "Skill not found", cause: "Invalid slug" },
151+
],
152+
},
153+
];

0 commit comments

Comments
 (0)