@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.
- package/dist/DocsLayout-BCVU6TTX.cjs +2027 -0
- package/dist/DocsLayout-BCVU6TTX.cjs.map +1 -0
- package/dist/DocsLayout-ERETJLLV.mjs +2020 -0
- package/dist/DocsLayout-ERETJLLV.mjs.map +1 -0
- package/dist/{PlaygroundLayout-O52C6HK5.css → DocsLayout-MBFIB4NO.css} +1 -1
- package/dist/{PrettyCode.client-SGDGQTYT.cjs → PrettyCode.client-5GABIN2I.cjs} +57 -35
- package/dist/PrettyCode.client-5GABIN2I.cjs.map +1 -0
- package/dist/{PrettyCode.client-DW5LTG47.mjs → PrettyCode.client-IZTXXYHG.mjs} +57 -35
- package/dist/PrettyCode.client-IZTXXYHG.mjs.map +1 -0
- package/dist/chunk-IULI4XII.cjs +1129 -0
- package/dist/chunk-IULI4XII.cjs.map +1 -0
- package/dist/chunk-VZGQC3NG.mjs +1100 -0
- package/dist/chunk-VZGQC3NG.mjs.map +1 -0
- package/dist/index.cjs +88 -552
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -6
- package/dist/index.d.ts +18 -6
- package/dist/index.mjs +25 -496
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +6 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
- package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
- package/src/tools/OpenapiViewer/README.md +104 -51
- package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
- package/src/tools/OpenapiViewer/components/index.ts +5 -2
- package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
- package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
- package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
- package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
- package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
- package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
- package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
- package/src/tools/OpenapiViewer/index.tsx +3 -7
- package/src/tools/OpenapiViewer/lazy.tsx +6 -27
- package/src/tools/OpenapiViewer/types.ts +44 -0
- package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
- package/src/tools/OpenapiViewer/utils/index.ts +3 -1
- package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
- package/src/tools/OpenapiViewer/utils/url.ts +202 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
- package/src/tools/PrettyCode/index.tsx +6 -0
- package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
- package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
- package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
- package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
- package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
- package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
- package/dist/chunk-5FKE7OME.cjs +0 -369
- package/dist/chunk-5FKE7OME.cjs.map +0 -1
- package/dist/chunk-BKWDHJKF.mjs +0 -356
- package/dist/chunk-BKWDHJKF.mjs.map +0 -1
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
- /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
- /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
|
+
}
|