@djangocfg/ui-tools 2.1.285 → 2.1.286

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/DocsLayout-BCVU6TTX.cjs +2027 -0
  2. package/dist/DocsLayout-BCVU6TTX.cjs.map +1 -0
  3. package/dist/DocsLayout-ERETJLLV.mjs +2020 -0
  4. package/dist/DocsLayout-ERETJLLV.mjs.map +1 -0
  5. package/dist/{PlaygroundLayout-O52C6HK5.css → DocsLayout-MBFIB4NO.css} +1 -1
  6. package/dist/{PrettyCode.client-SGDGQTYT.cjs → PrettyCode.client-5GABIN2I.cjs} +57 -35
  7. package/dist/PrettyCode.client-5GABIN2I.cjs.map +1 -0
  8. package/dist/{PrettyCode.client-DW5LTG47.mjs → PrettyCode.client-IZTXXYHG.mjs} +57 -35
  9. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +1 -0
  10. package/dist/chunk-IULI4XII.cjs +1129 -0
  11. package/dist/chunk-IULI4XII.cjs.map +1 -0
  12. package/dist/chunk-VZGQC3NG.mjs +1100 -0
  13. package/dist/chunk-VZGQC3NG.mjs.map +1 -0
  14. package/dist/index.cjs +88 -552
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +18 -6
  17. package/dist/index.d.ts +18 -6
  18. package/dist/index.mjs +25 -496
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +6 -6
  21. package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +4 -0
  22. package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
  23. package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
  24. package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
  25. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
  26. package/src/tools/OpenapiViewer/README.md +104 -51
  27. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
  28. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
  29. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
  30. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
  31. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
  32. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
  33. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
  34. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
  35. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
  39. package/src/tools/OpenapiViewer/components/index.ts +5 -2
  40. package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
  41. package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
  42. package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
  43. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
  44. package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
  45. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
  46. package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
  47. package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
  48. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
  49. package/src/tools/OpenapiViewer/index.tsx +3 -7
  50. package/src/tools/OpenapiViewer/lazy.tsx +6 -27
  51. package/src/tools/OpenapiViewer/types.ts +44 -0
  52. package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
  53. package/src/tools/OpenapiViewer/utils/index.ts +3 -1
  54. package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
  55. package/src/tools/OpenapiViewer/utils/url.ts +202 -0
  56. package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
  57. package/src/tools/PrettyCode/index.tsx +6 -0
  58. package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
  59. package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
  60. package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
  61. package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
  62. package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
  63. package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
  64. package/dist/chunk-5FKE7OME.cjs +0 -369
  65. package/dist/chunk-5FKE7OME.cjs.map +0 -1
  66. package/dist/chunk-BKWDHJKF.mjs +0 -356
  67. package/dist/chunk-BKWDHJKF.mjs.map +0 -1
  68. package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
  69. package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
  70. /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
  71. /package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ResponsePanel.tsx +0 -0
@@ -0,0 +1,268 @@
1
+ 'use client';
2
+
3
+ import { Check, Link2, Play } from 'lucide-react';
4
+ import React, { useCallback, useMemo, useState } from 'react';
5
+
6
+ import { Button, CopyButton } from '@djangocfg/ui-core/components';
7
+ import { cn } from '@djangocfg/ui-core/lib';
8
+
9
+ import { MarkdownMessage } from '../../../../components/markdown';
10
+ import type { ApiEndpoint } from '../../types';
11
+ import { endpointToMarkdown } from '../../utils/schemaExport';
12
+ import { MethodBadge, relativePath } from '../shared/ui';
13
+ import { endpointAnchor } from './anchor';
14
+ import { schemaToFields, type SchemaField } from './schemaFields';
15
+
16
+ interface EndpointDocProps {
17
+ endpoint: ApiEndpoint;
18
+ /** Is this endpoint currently loaded in the sticky playground? */
19
+ isLoadedInPlayground: boolean;
20
+ onTryIt: () => void;
21
+ }
22
+
23
+ export function EndpointDoc({ endpoint, isLoadedInPlayground, onTryIt }: EndpointDocProps) {
24
+ const anchor = endpointAnchor(endpoint);
25
+ const pathParams = endpoint.parameters?.filter((p) => endpoint.path.includes(`{${p.name}}`)) ?? [];
26
+ const queryParams = endpoint.parameters?.filter((p) => !endpoint.path.includes(`{${p.name}}`)) ?? [];
27
+
28
+ const [copied, setCopied] = useState(false);
29
+ const copyAnchor = useCallback(() => {
30
+ if (typeof window === 'undefined') return;
31
+ const url = `${window.location.origin}${window.location.pathname}#${anchor}`;
32
+ void navigator.clipboard?.writeText(url).then(() => {
33
+ setCopied(true);
34
+ setTimeout(() => setCopied(false), 1200);
35
+ });
36
+ }, [anchor]);
37
+
38
+ // Markdown summary used by the Copy-for-AI button. Memoised so moving
39
+ // the mouse over the section doesn't rebuild the same string.
40
+ const endpointMd = useMemo(() => endpointToMarkdown(endpoint), [endpoint]);
41
+
42
+ return (
43
+ <section
44
+ id={anchor}
45
+ data-endpoint-anchor={anchor}
46
+ className="scroll-mt-24 py-10 first:pt-0"
47
+ >
48
+ <header className="space-y-4">
49
+ <div className="flex items-start justify-between gap-3 flex-wrap">
50
+ <div className="flex items-baseline gap-2.5 min-w-0 flex-1 group/header">
51
+ <span className="shrink-0 translate-y-[2px]">
52
+ <MethodBadge method={endpoint.method} />
53
+ </span>
54
+ <code
55
+ className="font-mono text-sm md:text-[15px] font-medium text-foreground leading-snug min-w-0"
56
+ style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
57
+ >
58
+ {relativePath(endpoint.path)}
59
+ </code>
60
+ <button
61
+ type="button"
62
+ onClick={copyAnchor}
63
+ title="Copy link to this section"
64
+ className={cn(
65
+ 'shrink-0 p-1 rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-all',
66
+ 'opacity-0 group-hover/header:opacity-100',
67
+ copied && 'opacity-100 text-emerald-500',
68
+ )}
69
+ >
70
+ {copied ? <Check className="h-3.5 w-3.5" /> : <Link2 className="h-3.5 w-3.5" />}
71
+ </button>
72
+ <CopyButton
73
+ value={endpointMd}
74
+ title="Copy endpoint as markdown (for AI)"
75
+ variant="ghost"
76
+ size="icon"
77
+ iconClassName="h-3.5 w-3.5"
78
+ className={cn(
79
+ 'shrink-0 h-6 w-6 text-muted-foreground/40 hover:text-foreground',
80
+ 'opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100',
81
+ )}
82
+ />
83
+ </div>
84
+ <Button
85
+ size="sm"
86
+ variant={isLoadedInPlayground ? 'secondary' : 'default'}
87
+ onClick={onTryIt}
88
+ className="shrink-0 h-8 text-xs gap-1.5 lg:flex hidden"
89
+ >
90
+ <Play className="h-3 w-3" />
91
+ {isLoadedInPlayground ? 'Loaded' : 'Try it'}
92
+ </Button>
93
+ </div>
94
+ {endpoint.description && (
95
+ <div className="text-muted-foreground">
96
+ <MarkdownMessage content={endpoint.description} />
97
+ </div>
98
+ )}
99
+ {/* Mobile/tablet: Try it sits under the description so it's always reachable */}
100
+ <Button
101
+ size="sm"
102
+ variant={isLoadedInPlayground ? 'secondary' : 'default'}
103
+ onClick={onTryIt}
104
+ className="lg:hidden h-8 text-xs gap-1.5"
105
+ >
106
+ <Play className="h-3 w-3" />
107
+ {isLoadedInPlayground ? 'Loaded in playground' : 'Try it'}
108
+ </Button>
109
+ </header>
110
+
111
+ {(pathParams.length > 0 || queryParams.length > 0 || endpoint.requestBody || endpoint.responses?.length) && (
112
+ <div className="mt-8 space-y-8">
113
+ {pathParams.length > 0 && (
114
+ <ParamTable title="Path parameters" params={pathParams} />
115
+ )}
116
+ {queryParams.length > 0 && (
117
+ <ParamTable title="Query parameters" params={queryParams} />
118
+ )}
119
+ {endpoint.requestBody && (
120
+ <RequestBodySection body={endpoint.requestBody} />
121
+ )}
122
+ {endpoint.responses && endpoint.responses.length > 0 && (
123
+ <Subsection title="Responses">
124
+ <div className="divide-y border rounded-md overflow-hidden">
125
+ {endpoint.responses.map((r) => (
126
+ <div
127
+ key={r.code}
128
+ className="grid grid-cols-[72px_minmax(0,1fr)] items-center gap-3 px-3 py-2.5 bg-background"
129
+ >
130
+ <div className="flex justify-start">
131
+ <StatusTag code={r.code} />
132
+ </div>
133
+ <span className="text-sm text-muted-foreground leading-relaxed break-words">
134
+ {r.description}
135
+ </span>
136
+ </div>
137
+ ))}
138
+ </div>
139
+ </Subsection>
140
+ )}
141
+ </div>
142
+ )}
143
+ </section>
144
+ );
145
+ }
146
+
147
+ function RequestBodySection({ body }: { body: NonNullable<ApiEndpoint['requestBody']> }) {
148
+ const fields = useMemo(() => schemaToFields(body.schema), [body.schema]);
149
+ const typeLabel = body.schema
150
+ ? body.type === 'array'
151
+ ? `array<${(body.schema as { items?: { type?: string } }).items?.type ?? 'object'}>`
152
+ : body.type
153
+ : body.type;
154
+
155
+ return (
156
+ <Subsection title="Request body">
157
+ <div className="space-y-2">
158
+ <div className="flex items-baseline gap-2 flex-wrap">
159
+ <code className="font-mono text-[11px] text-muted-foreground/80">{typeLabel}</code>
160
+ {body.description && (
161
+ <span className="text-xs text-muted-foreground">{body.description}</span>
162
+ )}
163
+ </div>
164
+ {fields.length > 0 && <FieldsTable fields={fields} />}
165
+ </div>
166
+ </Subsection>
167
+ );
168
+ }
169
+
170
+ function FieldsTable({ fields }: { fields: SchemaField[] }) {
171
+ return (
172
+ <div className="divide-y border rounded-md overflow-hidden">
173
+ {fields.map((f) => (
174
+ <div key={f.name} className="px-3 py-2.5 bg-background space-y-1">
175
+ <div className="flex items-baseline gap-2 flex-wrap">
176
+ <code className="font-mono text-xs font-medium text-foreground">{f.name}</code>
177
+ {f.required && (
178
+ <span
179
+ title="Required"
180
+ className="text-[9px] text-destructive font-bold leading-none"
181
+ >
182
+ *
183
+ </span>
184
+ )}
185
+ <code className="font-mono text-[11px] text-muted-foreground/70">{f.type}</code>
186
+ </div>
187
+ {f.description && (
188
+ <p className="text-xs text-muted-foreground leading-relaxed break-words">
189
+ {f.description}
190
+ </p>
191
+ )}
192
+ </div>
193
+ ))}
194
+ </div>
195
+ );
196
+ }
197
+
198
+ function Subsection({ title, children }: { title: string; children: React.ReactNode }) {
199
+ return (
200
+ <div className="space-y-2.5">
201
+ <h4 className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
202
+ {title}
203
+ </h4>
204
+ {children}
205
+ </div>
206
+ );
207
+ }
208
+
209
+ function ParamTable({
210
+ title,
211
+ params,
212
+ }: {
213
+ title: string;
214
+ params: NonNullable<ApiEndpoint['parameters']>;
215
+ }) {
216
+ return (
217
+ <Subsection title={title}>
218
+ <div className="divide-y border rounded-md overflow-hidden">
219
+ {params.map((p) => (
220
+ <div key={p.name} className="px-3 py-2.5 bg-background space-y-1">
221
+ <div className="flex items-baseline gap-2 flex-wrap">
222
+ <code className="font-mono text-xs font-medium text-foreground">
223
+ {p.name}
224
+ </code>
225
+ {p.required && (
226
+ <span
227
+ title="Required"
228
+ className="text-[9px] text-destructive font-bold leading-none"
229
+ >
230
+ *
231
+ </span>
232
+ )}
233
+ <code className="font-mono text-[11px] text-muted-foreground/70">
234
+ {p.type}
235
+ </code>
236
+ </div>
237
+ {p.description ? (
238
+ <p className="text-xs text-muted-foreground leading-relaxed break-words">
239
+ {p.description}
240
+ </p>
241
+ ) : null}
242
+ </div>
243
+ ))}
244
+ </div>
245
+ </Subsection>
246
+ );
247
+ }
248
+
249
+ function StatusTag({ code }: { code: string }) {
250
+ const numeric = Number.parseInt(code, 10);
251
+ const cls = !Number.isFinite(numeric)
252
+ ? 'bg-muted text-muted-foreground border-border'
253
+ : numeric >= 500
254
+ ? 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25'
255
+ : numeric >= 400
256
+ ? 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25'
257
+ : numeric >= 300
258
+ ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25'
259
+ : 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25';
260
+ return (
261
+ <span className={cn(
262
+ 'inline-flex items-center justify-center rounded border px-2 py-0.5 font-mono text-[11px] font-bold leading-none shrink-0 tabular-nums',
263
+ cls,
264
+ )}>
265
+ {code}
266
+ </span>
267
+ );
268
+ }
@@ -0,0 +1,139 @@
1
+ 'use client';
2
+
3
+ import { Check, ChevronDown, Sparkles } from 'lucide-react';
4
+ import React, { useCallback, useMemo, useState } from 'react';
5
+
6
+ import {
7
+ Button,
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ } from '@djangocfg/ui-core/components';
15
+
16
+ import type { ApiEndpoint, OpenApiSchema } from '../../types';
17
+ import {
18
+ formatBytes,
19
+ toCompactJson,
20
+ toMarkdown,
21
+ toRawJson,
22
+ } from '../../utils/schemaExport';
23
+
24
+ type Flavour = 'markdown' | 'compact' | 'raw';
25
+
26
+ const FLAVOUR_LABELS: Record<Flavour, { title: string; hint: string }> = {
27
+ markdown: {
28
+ title: 'Markdown for LLM',
29
+ hint: 'Endpoints + params as prose. Smallest.',
30
+ },
31
+ compact: {
32
+ title: 'Compact JSON',
33
+ hint: 'Dereferenced, no examples, minified.',
34
+ },
35
+ raw: {
36
+ title: 'Raw JSON',
37
+ hint: 'Full OpenAPI document with $refs.',
38
+ },
39
+ };
40
+
41
+ interface SchemaCopyMenuProps {
42
+ schema: OpenApiSchema | null;
43
+ endpoints: ApiEndpoint[];
44
+ /** Resolved base URL that gets embedded into the copy so the AI
45
+ * receives working URLs, not the ones originally in ``schema.servers``. */
46
+ baseUrl?: string;
47
+ }
48
+
49
+ /**
50
+ * Per-schema copy dropdown. Shows three flavours tuned for different LLM
51
+ * use-cases. We compute each flavour lazily (only on click) because
52
+ * dereferencing + stringifying a large schema can be non-trivial — sizes
53
+ * are displayed after the first successful copy, via a tiny cache.
54
+ */
55
+ export function SchemaCopyMenu({ schema, endpoints, baseUrl }: SchemaCopyMenuProps) {
56
+ const [sizeCache, setSizeCache] = useState<Partial<Record<Flavour, string>>>({});
57
+ const [justCopied, setJustCopied] = useState<Flavour | null>(null);
58
+ const [open, setOpen] = useState(false);
59
+
60
+ const isReady = schema !== null && endpoints.length > 0;
61
+
62
+ const build = useCallback(
63
+ (flavour: Flavour): string => {
64
+ if (!schema) return '';
65
+ if (flavour === 'markdown') return toMarkdown(schema, endpoints, baseUrl);
66
+ if (flavour === 'compact') return toCompactJson(schema, baseUrl);
67
+ return toRawJson(schema, baseUrl);
68
+ },
69
+ [schema, endpoints, baseUrl],
70
+ );
71
+
72
+ const handleCopy = useCallback(
73
+ async (flavour: Flavour) => {
74
+ if (!isReady) return;
75
+ const text = build(flavour);
76
+ try {
77
+ await navigator.clipboard.writeText(text);
78
+ setSizeCache((prev) => ({ ...prev, [flavour]: formatBytes(text) }));
79
+ setJustCopied(flavour);
80
+ setTimeout(() => setJustCopied(null), 1500);
81
+ setOpen(false);
82
+ } catch {
83
+ // Silent: clipboard perm denied. CopyButton handles the same case.
84
+ }
85
+ },
86
+ [build, isReady],
87
+ );
88
+
89
+ const flavours = useMemo<Flavour[]>(() => ['markdown', 'compact', 'raw'], []);
90
+
91
+ return (
92
+ <DropdownMenu open={open} onOpenChange={setOpen}>
93
+ <DropdownMenuTrigger asChild>
94
+ <Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs" disabled={!isReady}>
95
+ <Sparkles className="h-3 w-3" />
96
+ Copy for AI
97
+ <ChevronDown className="h-3 w-3 opacity-60" />
98
+ </Button>
99
+ </DropdownMenuTrigger>
100
+ <DropdownMenuContent align="end" className="w-72">
101
+ <DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-muted-foreground/70">
102
+ Copy schema
103
+ </DropdownMenuLabel>
104
+ <DropdownMenuSeparator />
105
+ {flavours.map((f) => {
106
+ const label = FLAVOUR_LABELS[f];
107
+ const size = sizeCache[f];
108
+ const isDone = justCopied === f;
109
+ return (
110
+ <DropdownMenuItem
111
+ key={f}
112
+ onClick={(e) => {
113
+ e.preventDefault();
114
+ void handleCopy(f);
115
+ }}
116
+ className="flex flex-col items-start gap-0.5 py-2 cursor-pointer"
117
+ >
118
+ <div className="flex w-full items-center gap-2">
119
+ <span className="text-xs font-medium flex-1">{label.title}</span>
120
+ {isDone ? (
121
+ <span className="inline-flex items-center gap-1 text-[10px] text-emerald-500">
122
+ <Check className="h-3 w-3" /> Copied
123
+ </span>
124
+ ) : size ? (
125
+ <span className="text-[10px] font-mono text-muted-foreground/70 tabular-nums">
126
+ {size}
127
+ </span>
128
+ ) : null}
129
+ </div>
130
+ <span className="text-[10px] text-muted-foreground/70 leading-snug">
131
+ {label.hint}
132
+ </span>
133
+ </DropdownMenuItem>
134
+ );
135
+ })}
136
+ </DropdownMenuContent>
137
+ </DropdownMenu>
138
+ );
139
+ }
@@ -0,0 +1,211 @@
1
+ 'use client';
2
+
3
+ import { Search } from 'lucide-react';
4
+ import React, { useEffect, useMemo, useState } from 'react';
5
+
6
+ import {
7
+ Combobox,
8
+ Input,
9
+ Tooltip,
10
+ TooltipContent,
11
+ TooltipTrigger,
12
+ } from '@djangocfg/ui-core/components';
13
+ import { cn } from '@djangocfg/ui-core/lib';
14
+
15
+ import type { ApiEndpoint, OpenApiInfo, SchemaSource } from '../../types';
16
+ import { deduplicateEndpoints } from '../../utils/versionManager';
17
+ import { MethodBadge, ScrollArea } from '../shared/ui';
18
+ import { endpointAnchor } from './anchor';
19
+ import { longestCommonPrefix, sidebarLabel, sidebarTooltip } from './sidebarLabel';
20
+
21
+ type Group = {
22
+ category: string;
23
+ endpoints: ApiEndpoint[];
24
+ /** Longest ``/``-aligned prefix shared by every endpoint in this group.
25
+ * Stripped from labels that fall back to showing the path. */
26
+ commonPrefix: string;
27
+ };
28
+
29
+ const METHOD_ORDER: Record<string, number> = {
30
+ GET: 0, POST: 1, PUT: 2, PATCH: 3, DELETE: 4,
31
+ };
32
+
33
+ function groupEndpoints(list: ApiEndpoint[]): Group[] {
34
+ const map = new Map<string, ApiEndpoint[]>();
35
+ for (const ep of list) {
36
+ const arr = map.get(ep.category) ?? [];
37
+ arr.push(ep);
38
+ map.set(ep.category, arr);
39
+ }
40
+ const groups: Group[] = Array.from(map.entries()).map(([category, endpoints]) => ({
41
+ category,
42
+ endpoints: [...endpoints].sort((a, b) => {
43
+ const byPath = a.path.localeCompare(b.path);
44
+ if (byPath !== 0) return byPath;
45
+ return (METHOD_ORDER[a.method] ?? 99) - (METHOD_ORDER[b.method] ?? 99);
46
+ }),
47
+ commonPrefix: longestCommonPrefix(endpoints.map((e) => e.path)),
48
+ }));
49
+ // Alphabetical, but "Other" sinks to the bottom.
50
+ groups.sort((a, b) => {
51
+ if (a.category === 'Other') return 1;
52
+ if (b.category === 'Other') return -1;
53
+ return a.category.localeCompare(b.category);
54
+ });
55
+ return groups;
56
+ }
57
+
58
+ export interface DocsSidebarProps {
59
+ info: OpenApiInfo | null;
60
+ endpoints: ApiEndpoint[];
61
+ schemas: SchemaSource[];
62
+ currentSchemaId: string | null;
63
+ onSchemaChange: (id: string) => void;
64
+ activeEndpointId: string | null;
65
+ selectedVersion: string;
66
+ onNavigate: (anchor: string) => void;
67
+ }
68
+
69
+ export function DocsSidebar({
70
+ info,
71
+ endpoints,
72
+ schemas,
73
+ currentSchemaId,
74
+ onSchemaChange,
75
+ activeEndpointId,
76
+ selectedVersion,
77
+ onNavigate,
78
+ }: DocsSidebarProps) {
79
+ const [search, setSearch] = useState('');
80
+ const [debounced, setDebounced] = useState('');
81
+
82
+ useEffect(() => {
83
+ const id = setTimeout(() => setDebounced(search), 120);
84
+ return () => clearTimeout(id);
85
+ }, [search]);
86
+
87
+ const filteredGroups = useMemo(() => {
88
+ let list = deduplicateEndpoints(endpoints, selectedVersion);
89
+ if (debounced) {
90
+ const q = debounced.toLowerCase();
91
+ list = list.filter((e) =>
92
+ e.summary.toLowerCase().includes(q) ||
93
+ e.name.toLowerCase().includes(q) ||
94
+ e.description.toLowerCase().includes(q) ||
95
+ e.path.toLowerCase().includes(q),
96
+ );
97
+ }
98
+ return groupEndpoints(list);
99
+ }, [endpoints, debounced, selectedVersion]);
100
+
101
+ const schemaOptions = useMemo(
102
+ () => schemas.map((s) => ({ value: s.id, label: s.name })),
103
+ [schemas],
104
+ );
105
+ const hasMultipleSchemas = schemas.length > 1;
106
+ const apiTitle = info?.title ?? 'API Reference';
107
+
108
+ return (
109
+ <aside className="flex flex-col min-h-0 border-r bg-muted/10">
110
+ {/* Brand row */}
111
+ <div className="shrink-0 border-b px-4 h-12 flex items-center gap-2">
112
+ <span className="text-[13px] font-semibold text-foreground truncate">
113
+ {apiTitle}
114
+ </span>
115
+ {info?.version && (
116
+ <span className="font-mono text-[10px] text-muted-foreground/70 shrink-0">
117
+ v{info.version}
118
+ </span>
119
+ )}
120
+ </div>
121
+
122
+ {/* Controls */}
123
+ <div className="shrink-0 border-b px-3 py-3 space-y-2">
124
+ {hasMultipleSchemas && (
125
+ <Combobox
126
+ options={schemaOptions}
127
+ value={currentSchemaId ?? ''}
128
+ onValueChange={(id) => id && onSchemaChange(id)}
129
+ placeholder="Select API"
130
+ searchPlaceholder="Search APIs…"
131
+ emptyText="No APIs found"
132
+ className="w-full h-8 text-xs"
133
+ />
134
+ )}
135
+ <div className="relative">
136
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50 pointer-events-none" />
137
+ <Input
138
+ placeholder="Search endpoints…"
139
+ value={search}
140
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
141
+ className="pl-8 h-8 text-xs"
142
+ />
143
+ </div>
144
+ </div>
145
+
146
+ <ScrollArea>
147
+ {filteredGroups.length === 0 ? (
148
+ <div className="py-10 px-4 text-center text-xs text-muted-foreground">
149
+ {debounced
150
+ ? `No endpoints match "${debounced}"`
151
+ : 'No endpoints in this schema'}
152
+ </div>
153
+ ) : (
154
+ <nav className="py-2">
155
+ {filteredGroups.map((group) => (
156
+ <div key={group.category} className="mb-4 last:mb-2">
157
+ <div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/50 select-none">
158
+ {group.category}
159
+ </div>
160
+ <div>
161
+ {group.endpoints.map((ep) => {
162
+ const anchor = endpointAnchor(ep);
163
+ const isActive = activeEndpointId === anchor;
164
+ const label = sidebarLabel(ep, group.commonPrefix);
165
+ const tooltip = sidebarTooltip(ep);
166
+ // Summary → sans-serif (reads like an outline).
167
+ // Path-tail fallback → mono (reads like code).
168
+ const useMono = !ep.summary;
169
+ return (
170
+ <Tooltip key={`${ep.method}-${ep.path}`} delayDuration={350}>
171
+ <TooltipTrigger asChild>
172
+ <button
173
+ onClick={() => onNavigate(anchor)}
174
+ aria-current={isActive ? 'location' : undefined}
175
+ className={cn(
176
+ 'relative group w-full text-left flex items-center gap-2 pl-4 pr-3 py-1.5 transition-colors',
177
+ isActive
178
+ ? 'bg-primary/10 text-foreground'
179
+ : 'hover:bg-muted/40 text-foreground/75 hover:text-foreground',
180
+ )}
181
+ >
182
+ {isActive && (
183
+ <span className="absolute left-0 top-1 bottom-1 w-0.5 rounded-r bg-primary" />
184
+ )}
185
+ <MethodBadge method={ep.method} />
186
+ <span
187
+ className={cn(
188
+ 'truncate leading-tight flex-1 min-w-0',
189
+ useMono ? 'font-mono text-[11px]' : 'text-[12px]',
190
+ isActive && 'text-foreground font-medium',
191
+ )}
192
+ >
193
+ {label}
194
+ </span>
195
+ </button>
196
+ </TooltipTrigger>
197
+ <TooltipContent side="right" align="center" className="font-mono text-[11px]">
198
+ {tooltip}
199
+ </TooltipContent>
200
+ </Tooltip>
201
+ );
202
+ })}
203
+ </div>
204
+ </div>
205
+ ))}
206
+ </nav>
207
+ )}
208
+ </ScrollArea>
209
+ </aside>
210
+ );
211
+ }