@djangocfg/ui-tools 2.1.285 → 2.1.287

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 (79) 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 +6 -0
  22. package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
  23. package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
  24. package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
  25. package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
  26. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
  27. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
  28. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
  29. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
  30. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -0
  31. package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
  32. package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
  33. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
  34. package/src/tools/OpenapiViewer/README.md +104 -51
  35. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
  39. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
  40. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
  41. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
  42. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
  43. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
  44. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
  45. package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
  46. package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
  47. package/src/tools/OpenapiViewer/components/index.ts +5 -2
  48. package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
  49. package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
  50. package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
  51. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
  52. package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
  53. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
  54. package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
  55. package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
  56. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
  57. package/src/tools/OpenapiViewer/index.tsx +3 -7
  58. package/src/tools/OpenapiViewer/lazy.tsx +6 -27
  59. package/src/tools/OpenapiViewer/types.ts +44 -0
  60. package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
  61. package/src/tools/OpenapiViewer/utils/index.ts +3 -1
  62. package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
  63. package/src/tools/OpenapiViewer/utils/url.ts +202 -0
  64. package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
  65. package/src/tools/PrettyCode/index.tsx +6 -0
  66. package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
  67. package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
  68. package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
  69. package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
  70. package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
  71. package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
  72. package/dist/chunk-5FKE7OME.cjs +0 -369
  73. package/dist/chunk-5FKE7OME.cjs.map +0 -1
  74. package/dist/chunk-BKWDHJKF.mjs +0 -356
  75. package/dist/chunk-BKWDHJKF.mjs.map +0 -1
  76. package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
  77. package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
  78. /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
  79. /package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ResponsePanel.tsx +0 -0
@@ -0,0 +1,137 @@
1
+ 'use client';
2
+
3
+ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
4
+
5
+ import type { ApiEndpoint, OpenApiInfo, OpenApiSchema } from '../../types';
6
+ import { deduplicateEndpoints } from '../../utils/versionManager';
7
+ import { ApiIntroSection } from './ApiIntroSection';
8
+ import { EndpointDoc } from './EndpointDoc';
9
+ import { endpointAnchor } from './anchor';
10
+
11
+ export interface DocsViewHandle {
12
+ scrollToAnchor: (anchor: string) => void;
13
+ }
14
+
15
+ interface DocsViewProps {
16
+ info: OpenApiInfo | null;
17
+ rawSchema: OpenApiSchema | null;
18
+ resolvedBaseUrl?: string;
19
+ endpoints: ApiEndpoint[];
20
+ selectedVersion: string;
21
+ loadedEndpoint: ApiEndpoint | null;
22
+ onTryEndpoint: (ep: ApiEndpoint) => void;
23
+ onActiveChange: (anchor: string | null) => void;
24
+ }
25
+
26
+ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function DocsView(
27
+ {
28
+ info,
29
+ rawSchema,
30
+ resolvedBaseUrl,
31
+ endpoints,
32
+ selectedVersion,
33
+ loadedEndpoint,
34
+ onTryEndpoint,
35
+ onActiveChange,
36
+ },
37
+ ref,
38
+ ) {
39
+ const scrollRef = useRef<HTMLDivElement | null>(null);
40
+
41
+ const visibleEndpoints = useMemo(
42
+ () => deduplicateEndpoints(endpoints, selectedVersion),
43
+ [endpoints, selectedVersion],
44
+ );
45
+
46
+ // Scroll a given section into view. Imperative handle so the
47
+ // sidebar (not a descendant) can trigger this without props drilling.
48
+ const scrollToAnchor = useCallback((anchor: string) => {
49
+ const root = scrollRef.current;
50
+ if (!root) return;
51
+ const el = root.querySelector<HTMLElement>(`[data-endpoint-anchor="${anchor}"]`);
52
+ if (!el) return;
53
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
54
+ }, []);
55
+
56
+ React.useImperativeHandle(ref, () => ({ scrollToAnchor }), [scrollToAnchor]);
57
+
58
+ // Scrollspy: pick the topmost endpoint section whose top is near the
59
+ // upper third of the viewport. Runs on every scroll event (rAF-throttled)
60
+ // — an IntersectionObserver has flaky behaviour for "which one is active"
61
+ // when several sections overlap the root margin band at once.
62
+ useEffect(() => {
63
+ const root = scrollRef.current;
64
+ if (!root) return;
65
+
66
+ let rafId = 0;
67
+ let lastActive: string | null = null;
68
+
69
+ const compute = () => {
70
+ rafId = 0;
71
+ const sections = root.querySelectorAll<HTMLElement>('[data-endpoint-anchor]');
72
+ if (sections.length === 0) return;
73
+ const rootTop = root.getBoundingClientRect().top;
74
+ const threshold = rootTop + root.clientHeight * 0.25;
75
+ let active: string | null = null;
76
+ for (const s of Array.from(sections)) {
77
+ const top = s.getBoundingClientRect().top;
78
+ if (top <= threshold) {
79
+ active = s.dataset.endpointAnchor ?? null;
80
+ } else {
81
+ break;
82
+ }
83
+ }
84
+ if (active !== lastActive) {
85
+ lastActive = active;
86
+ onActiveChange(active);
87
+ }
88
+ };
89
+
90
+ const onScroll = () => {
91
+ if (rafId) return;
92
+ rafId = requestAnimationFrame(compute);
93
+ };
94
+
95
+ compute();
96
+ root.addEventListener('scroll', onScroll, { passive: true });
97
+ return () => {
98
+ root.removeEventListener('scroll', onScroll);
99
+ if (rafId) cancelAnimationFrame(rafId);
100
+ };
101
+ }, [visibleEndpoints, onActiveChange]);
102
+
103
+ return (
104
+ <div ref={scrollRef} className="flex-1 overflow-y-auto min-h-0">
105
+ <div className="mx-auto w-full max-w-[860px] px-6 md:px-10 lg:px-14 py-12">
106
+ {info && (
107
+ <ApiIntroSection
108
+ info={info}
109
+ schema={rawSchema}
110
+ endpoints={visibleEndpoints}
111
+ resolvedBaseUrl={resolvedBaseUrl}
112
+ />
113
+ )}
114
+ {visibleEndpoints.length === 0 ? (
115
+ <div className="py-16 text-center text-sm text-muted-foreground">
116
+ No endpoints to display.
117
+ </div>
118
+ ) : (
119
+ <div className="divide-y divide-border/60">
120
+ {visibleEndpoints.map((ep) => {
121
+ const isLoaded =
122
+ loadedEndpoint?.method === ep.method && loadedEndpoint?.path === ep.path;
123
+ return (
124
+ <EndpointDoc
125
+ key={`${ep.method}-${ep.path}`}
126
+ endpoint={ep}
127
+ isLoadedInPlayground={isLoaded}
128
+ onTryIt={() => onTryEndpoint(ep)}
129
+ />
130
+ );
131
+ })}
132
+ </div>
133
+ )}
134
+ </div>
135
+ </div>
136
+ );
137
+ });
@@ -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
+ }