@djangocfg/ui-tools 2.1.286 → 2.1.289
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-ERETJLLV.mjs → DocsLayout-TKJQ5W5E.mjs} +848 -266
- package/dist/DocsLayout-TKJQ5W5E.mjs.map +1 -0
- package/dist/{DocsLayout-BCVU6TTX.cjs → DocsLayout-YDR7DSMM.cjs} +843 -261
- package/dist/DocsLayout-YDR7DSMM.cjs.map +1 -0
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.mjs +2 -2
- package/package.json +9 -6
- package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +2 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -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 +2 -2
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +27 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +326 -54
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +7 -2
- package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +32 -9
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +348 -120
- package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +19 -2
- package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +38 -21
- package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +168 -50
- package/src/tools/OpenapiViewer/hooks/index.ts +3 -1
- package/src/tools/OpenapiViewer/hooks/useDocsUrlSync.ts +119 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +127 -7
- package/src/tools/OpenapiViewer/types.ts +36 -1
- package/src/tools/OpenapiViewer/utils/scrollParent.ts +68 -0
- package/dist/DocsLayout-BCVU6TTX.cjs.map +0 -1
- package/dist/DocsLayout-ERETJLLV.mjs.map +0 -1
|
@@ -2,17 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
4
|
|
|
5
|
-
import type { ApiEndpoint, OpenApiInfo, OpenApiSchema } from '../../types';
|
|
5
|
+
import type { ApiEndpoint, LoadedSchemaEntry, OpenApiInfo, OpenApiSchema } from '../../types';
|
|
6
|
+
import {
|
|
7
|
+
getScrollParent,
|
|
8
|
+
getScrollTop,
|
|
9
|
+
getTargetTop,
|
|
10
|
+
getViewportHeight,
|
|
11
|
+
scrollTargetTo,
|
|
12
|
+
type ScrollTarget,
|
|
13
|
+
} from '../../utils/scrollParent';
|
|
6
14
|
import { deduplicateEndpoints } from '../../utils/versionManager';
|
|
7
15
|
import { ApiIntroSection } from './ApiIntroSection';
|
|
8
16
|
import { EndpointDoc } from './EndpointDoc';
|
|
9
|
-
import {
|
|
17
|
+
import { SchemaCopyMenu } from './SchemaCopyMenu';
|
|
10
18
|
|
|
11
19
|
export interface DocsViewHandle {
|
|
12
20
|
scrollToAnchor: (anchor: string) => void;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
// ─── Props ───────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
interface SelectorProps {
|
|
26
|
+
grouping?: 'selector';
|
|
16
27
|
info: OpenApiInfo | null;
|
|
17
28
|
rawSchema: OpenApiSchema | null;
|
|
18
29
|
resolvedBaseUrl?: string;
|
|
@@ -20,48 +31,169 @@ interface DocsViewProps {
|
|
|
20
31
|
selectedVersion: string;
|
|
21
32
|
loadedEndpoint: ApiEndpoint | null;
|
|
22
33
|
onTryEndpoint: (ep: ApiEndpoint) => void;
|
|
23
|
-
onActiveChange: (anchor: string | null) => void;
|
|
34
|
+
onActiveChange: (anchor: string | null, schemaId: string | null) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SectionsProps {
|
|
38
|
+
grouping: 'sections';
|
|
39
|
+
/** Per-schema data (info + endpoints). Rendered in order. */
|
|
40
|
+
schemasData: LoadedSchemaEntry[];
|
|
41
|
+
selectedVersion: string;
|
|
42
|
+
loadedEndpoint: ApiEndpoint | null;
|
|
43
|
+
onTryEndpoint: (ep: ApiEndpoint) => void;
|
|
44
|
+
onActiveChange: (anchor: string | null, schemaId: string | null) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type DocsViewProps = SelectorProps | SectionsProps;
|
|
48
|
+
|
|
49
|
+
// ─── View-model types ────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
interface EndpointRow {
|
|
52
|
+
key: string;
|
|
53
|
+
endpoint: ApiEndpoint;
|
|
54
|
+
isLoaded: boolean;
|
|
55
|
+
schemaId: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type SectionState =
|
|
59
|
+
| { kind: 'ready'; rows: EndpointRow[] }
|
|
60
|
+
| { kind: 'loading' }
|
|
61
|
+
| { kind: 'error'; message: string }
|
|
62
|
+
| { kind: 'empty' };
|
|
63
|
+
|
|
64
|
+
interface SchemaSectionVM {
|
|
65
|
+
schemaId: string;
|
|
66
|
+
title: string;
|
|
67
|
+
version: string | null;
|
|
68
|
+
description: string | null;
|
|
69
|
+
state: SectionState;
|
|
70
|
+
/** Copy-for-AI payload. ``null`` when the section is still loading
|
|
71
|
+
* or failed — the dropdown stays disabled. */
|
|
72
|
+
rawSchema: OpenApiSchema | null;
|
|
73
|
+
baseUrl: string | undefined;
|
|
74
|
+
allEndpoints: ApiEndpoint[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** Pixel offset from the top of the scroll container where the viewer
|
|
80
|
+
* should "park" sections. Reads ``--navbar-height`` for back-compat
|
|
81
|
+
* with pages that already set it; defaults to ``0`` for embedded /
|
|
82
|
+
* no-navbar setups (the common case when hosted in a shell). */
|
|
83
|
+
const readNavbarOffset = () => {
|
|
84
|
+
if (typeof document === 'undefined') return 0;
|
|
85
|
+
const raw = getComputedStyle(document.documentElement).getPropertyValue('--navbar-height');
|
|
86
|
+
const parsed = parseInt(raw || '', 10);
|
|
87
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const isSameEndpoint = (a: ApiEndpoint | null, b: ApiEndpoint) =>
|
|
91
|
+
a !== null && a.method === b.method && a.path === b.path;
|
|
92
|
+
|
|
93
|
+
function buildEndpointRow(
|
|
94
|
+
ep: ApiEndpoint,
|
|
95
|
+
loadedEndpoint: ApiEndpoint | null,
|
|
96
|
+
schemaId: string | null,
|
|
97
|
+
): EndpointRow {
|
|
98
|
+
const keySchema = schemaId ? `${schemaId}-` : '';
|
|
99
|
+
return {
|
|
100
|
+
key: `${keySchema}${ep.method}-${ep.path}`,
|
|
101
|
+
endpoint: ep,
|
|
102
|
+
isLoaded: isSameEndpoint(loadedEndpoint, ep),
|
|
103
|
+
schemaId,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildSchemaSectionVM(
|
|
108
|
+
entry: LoadedSchemaEntry,
|
|
109
|
+
selectedVersion: string,
|
|
110
|
+
loadedEndpoint: ApiEndpoint | null,
|
|
111
|
+
): SchemaSectionVM {
|
|
112
|
+
const title = entry.info?.title ?? entry.source.name;
|
|
113
|
+
const version = entry.info?.version ?? null;
|
|
114
|
+
const description = entry.info?.description ?? null;
|
|
115
|
+
|
|
116
|
+
let state: SectionState;
|
|
117
|
+
if (entry.loading) {
|
|
118
|
+
state = { kind: 'loading' };
|
|
119
|
+
} else if (entry.error) {
|
|
120
|
+
state = { kind: 'error', message: entry.error };
|
|
121
|
+
} else {
|
|
122
|
+
const visible = deduplicateEndpoints(entry.endpoints, selectedVersion);
|
|
123
|
+
state = visible.length === 0
|
|
124
|
+
? { kind: 'empty' }
|
|
125
|
+
: {
|
|
126
|
+
kind: 'ready',
|
|
127
|
+
rows: visible.map((ep) => buildEndpointRow(ep, loadedEndpoint, entry.source.id)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
schemaId: entry.source.id,
|
|
133
|
+
title,
|
|
134
|
+
version,
|
|
135
|
+
description,
|
|
136
|
+
state,
|
|
137
|
+
rawSchema: entry.rawSchema,
|
|
138
|
+
baseUrl: entry.resolvedBaseUrl,
|
|
139
|
+
allEndpoints: entry.endpoints,
|
|
140
|
+
};
|
|
24
141
|
}
|
|
25
142
|
|
|
143
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
26
145
|
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
|
-
},
|
|
146
|
+
props,
|
|
37
147
|
ref,
|
|
38
148
|
) {
|
|
39
149
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
150
|
+
const scrollTargetRef = useRef<ScrollTarget | null>(null);
|
|
151
|
+
const { onActiveChange } = props;
|
|
40
152
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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' });
|
|
153
|
+
// Resolve the real scroll container once the ref is attached. In
|
|
154
|
+
// standalone pages that's ``window``; inside an ``overflow-auto``
|
|
155
|
+
// shell (dev playground, modal) it's the wrapping DIV.
|
|
156
|
+
const ensureScrollTarget = useCallback((): ScrollTarget | null => {
|
|
157
|
+
if (scrollTargetRef.current) return scrollTargetRef.current;
|
|
158
|
+
if (!scrollRef.current) return null;
|
|
159
|
+
scrollTargetRef.current = getScrollParent(scrollRef.current);
|
|
160
|
+
return scrollTargetRef.current;
|
|
54
161
|
}, []);
|
|
55
162
|
|
|
163
|
+
// Scroll a given section into view. Works against whichever ancestor
|
|
164
|
+
// actually scrolls — window for standalone, the overflow-auto parent
|
|
165
|
+
// for embedded layouts — so callers don't need to know the difference.
|
|
166
|
+
const scrollToAnchor = useCallback(
|
|
167
|
+
(anchor: string) => {
|
|
168
|
+
const root = scrollRef.current;
|
|
169
|
+
if (!root) return;
|
|
170
|
+
const el = root.querySelector<HTMLElement>(`[data-endpoint-anchor="${anchor}"]`);
|
|
171
|
+
if (!el) return;
|
|
172
|
+
const target = ensureScrollTarget();
|
|
173
|
+
if (!target) return;
|
|
174
|
+
const navbar = readNavbarOffset();
|
|
175
|
+
const top =
|
|
176
|
+
el.getBoundingClientRect().top -
|
|
177
|
+
getTargetTop(target) +
|
|
178
|
+
getScrollTop(target) -
|
|
179
|
+
navbar -
|
|
180
|
+
8;
|
|
181
|
+
scrollTargetTo(target, top);
|
|
182
|
+
},
|
|
183
|
+
[ensureScrollTarget],
|
|
184
|
+
);
|
|
185
|
+
|
|
56
186
|
React.useImperativeHandle(ref, () => ({ scrollToAnchor }), [scrollToAnchor]);
|
|
57
187
|
|
|
58
188
|
// Scrollspy: pick the topmost endpoint section whose top is near the
|
|
59
|
-
// upper
|
|
60
|
-
//
|
|
61
|
-
//
|
|
189
|
+
// upper quarter of the viewport. Listens on the real scroll container
|
|
190
|
+
// (see ``ensureScrollTarget``) because ``scroll`` events on a nested
|
|
191
|
+
// overflow:auto element do NOT bubble up to window.
|
|
62
192
|
useEffect(() => {
|
|
63
193
|
const root = scrollRef.current;
|
|
64
194
|
if (!root) return;
|
|
195
|
+
const target = ensureScrollTarget();
|
|
196
|
+
if (!target) return;
|
|
65
197
|
|
|
66
198
|
let rafId = 0;
|
|
67
199
|
let lastActive: string | null = null;
|
|
@@ -70,20 +202,22 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
|
|
|
70
202
|
rafId = 0;
|
|
71
203
|
const sections = root.querySelectorAll<HTMLElement>('[data-endpoint-anchor]');
|
|
72
204
|
if (sections.length === 0) return;
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
205
|
+
const navbar = readNavbarOffset();
|
|
206
|
+
const viewportTop = getTargetTop(target);
|
|
207
|
+
const threshold = viewportTop + navbar + getViewportHeight(target) * 0.25;
|
|
208
|
+
let active: HTMLElement | null = null;
|
|
76
209
|
for (const s of Array.from(sections)) {
|
|
77
210
|
const top = s.getBoundingClientRect().top;
|
|
78
211
|
if (top <= threshold) {
|
|
79
|
-
active = s
|
|
212
|
+
active = s;
|
|
80
213
|
} else {
|
|
81
214
|
break;
|
|
82
215
|
}
|
|
83
216
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
217
|
+
const anchor = active?.dataset.endpointAnchor ?? null;
|
|
218
|
+
if (anchor !== lastActive) {
|
|
219
|
+
lastActive = anchor;
|
|
220
|
+
onActiveChange(anchor, active?.dataset.schemaId || null);
|
|
87
221
|
}
|
|
88
222
|
};
|
|
89
223
|
|
|
@@ -93,15 +227,49 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
|
|
|
93
227
|
};
|
|
94
228
|
|
|
95
229
|
compute();
|
|
96
|
-
|
|
230
|
+
target.addEventListener('scroll', onScroll, { passive: true });
|
|
231
|
+
// Resize always bubbles to window — listen there regardless of target.
|
|
232
|
+
window.addEventListener('resize', onScroll, { passive: true });
|
|
97
233
|
return () => {
|
|
98
|
-
|
|
234
|
+
target.removeEventListener('scroll', onScroll);
|
|
235
|
+
window.removeEventListener('resize', onScroll);
|
|
99
236
|
if (rafId) cancelAnimationFrame(rafId);
|
|
100
237
|
};
|
|
101
|
-
}, [
|
|
238
|
+
}, [onActiveChange, ensureScrollTarget, props]);
|
|
239
|
+
|
|
240
|
+
if (props.grouping === 'sections') {
|
|
241
|
+
return <SectionsBody scrollRef={scrollRef} {...props} />;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return <SelectorBody scrollRef={scrollRef} {...props} />;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ─── Selector body (single active schema) ────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
function SelectorBody({
|
|
250
|
+
scrollRef,
|
|
251
|
+
info,
|
|
252
|
+
rawSchema,
|
|
253
|
+
resolvedBaseUrl,
|
|
254
|
+
endpoints,
|
|
255
|
+
selectedVersion,
|
|
256
|
+
loadedEndpoint,
|
|
257
|
+
onTryEndpoint,
|
|
258
|
+
}: SelectorProps & { scrollRef: React.RefObject<HTMLDivElement | null> }) {
|
|
259
|
+
const visibleEndpoints = useMemo(
|
|
260
|
+
() => deduplicateEndpoints(endpoints, selectedVersion),
|
|
261
|
+
[endpoints, selectedVersion],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const rows = useMemo<EndpointRow[]>(
|
|
265
|
+
() => visibleEndpoints.map((ep) => buildEndpointRow(ep, loadedEndpoint, ep.schemaId ?? null)),
|
|
266
|
+
[visibleEndpoints, loadedEndpoint],
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const isEmpty = rows.length === 0;
|
|
102
270
|
|
|
103
271
|
return (
|
|
104
|
-
<div ref={scrollRef}
|
|
272
|
+
<div ref={scrollRef}>
|
|
105
273
|
<div className="mx-auto w-full max-w-[860px] px-6 md:px-10 lg:px-14 py-12">
|
|
106
274
|
{info && (
|
|
107
275
|
<ApiIntroSection
|
|
@@ -111,27 +279,131 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
|
|
|
111
279
|
resolvedBaseUrl={resolvedBaseUrl}
|
|
112
280
|
/>
|
|
113
281
|
)}
|
|
114
|
-
{
|
|
282
|
+
{isEmpty ? (
|
|
115
283
|
<div className="py-16 text-center text-sm text-muted-foreground">
|
|
116
284
|
No endpoints to display.
|
|
117
285
|
</div>
|
|
118
286
|
) : (
|
|
119
287
|
<div className="divide-y divide-border/60">
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
/>
|
|
130
|
-
);
|
|
131
|
-
})}
|
|
288
|
+
{rows.map((row) => (
|
|
289
|
+
<EndpointDoc
|
|
290
|
+
key={row.key}
|
|
291
|
+
endpoint={row.endpoint}
|
|
292
|
+
isLoadedInPlayground={row.isLoaded}
|
|
293
|
+
onTryIt={() => onTryEndpoint(row.endpoint)}
|
|
294
|
+
schemaId={row.schemaId}
|
|
295
|
+
/>
|
|
296
|
+
))}
|
|
132
297
|
</div>
|
|
133
298
|
)}
|
|
134
299
|
</div>
|
|
135
300
|
</div>
|
|
136
301
|
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Sections body (all schemas concatenated) ────────────────────────────────
|
|
305
|
+
|
|
306
|
+
function SectionsBody({
|
|
307
|
+
scrollRef,
|
|
308
|
+
schemasData,
|
|
309
|
+
selectedVersion,
|
|
310
|
+
loadedEndpoint,
|
|
311
|
+
onTryEndpoint,
|
|
312
|
+
}: SectionsProps & { scrollRef: React.RefObject<HTMLDivElement | null> }) {
|
|
313
|
+
const sections = useMemo<SchemaSectionVM[]>(
|
|
314
|
+
() => schemasData.map((e) => buildSchemaSectionVM(e, selectedVersion, loadedEndpoint)),
|
|
315
|
+
[schemasData, selectedVersion, loadedEndpoint],
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div ref={scrollRef}>
|
|
320
|
+
<div className="mx-auto w-full max-w-[860px] px-6 md:px-10 lg:px-14 py-12 space-y-16">
|
|
321
|
+
{sections.map((section) => (
|
|
322
|
+
<SchemaSectionView key={section.schemaId} section={section} onTryEndpoint={onTryEndpoint} />
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const SchemaSectionView = React.memo(function SchemaSectionView({
|
|
330
|
+
section,
|
|
331
|
+
onTryEndpoint,
|
|
332
|
+
}: {
|
|
333
|
+
section: SchemaSectionVM;
|
|
334
|
+
onTryEndpoint: (ep: ApiEndpoint) => void;
|
|
335
|
+
}) {
|
|
336
|
+
const canCopy = section.rawSchema !== null && section.allEndpoints.length > 0;
|
|
337
|
+
return (
|
|
338
|
+
<section data-schema-anchor={section.schemaId} className="scroll-mt-20">
|
|
339
|
+
<header className="mb-8 pb-4 border-b">
|
|
340
|
+
<div className="flex items-start justify-between gap-4">
|
|
341
|
+
<div className="flex items-baseline gap-3 min-w-0">
|
|
342
|
+
<h2 className="text-2xl font-semibold tracking-tight">{section.title}</h2>
|
|
343
|
+
{section.version && (
|
|
344
|
+
<span className="font-mono text-xs text-muted-foreground/70">
|
|
345
|
+
v{section.version}
|
|
346
|
+
</span>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
{canCopy && (
|
|
350
|
+
<SchemaCopyMenu
|
|
351
|
+
schema={section.rawSchema}
|
|
352
|
+
endpoints={section.allEndpoints}
|
|
353
|
+
baseUrl={section.baseUrl}
|
|
354
|
+
/>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
{section.description && (
|
|
358
|
+
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap">
|
|
359
|
+
{section.description}
|
|
360
|
+
</p>
|
|
361
|
+
)}
|
|
362
|
+
</header>
|
|
363
|
+
<SchemaSectionStateView section={section} onTryEndpoint={onTryEndpoint} />
|
|
364
|
+
</section>
|
|
365
|
+
);
|
|
137
366
|
});
|
|
367
|
+
|
|
368
|
+
function SchemaSectionStateView({
|
|
369
|
+
section,
|
|
370
|
+
onTryEndpoint,
|
|
371
|
+
}: {
|
|
372
|
+
section: SchemaSectionVM;
|
|
373
|
+
onTryEndpoint: (ep: ApiEndpoint) => void;
|
|
374
|
+
}) {
|
|
375
|
+
switch (section.state.kind) {
|
|
376
|
+
case 'loading':
|
|
377
|
+
return (
|
|
378
|
+
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
379
|
+
Loading {section.title}…
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
case 'error':
|
|
383
|
+
return (
|
|
384
|
+
<div className="py-8 text-center text-sm text-destructive">
|
|
385
|
+
Failed to load {section.title}: {section.state.message}
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
case 'empty':
|
|
389
|
+
return (
|
|
390
|
+
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
391
|
+
No endpoints in this schema.
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
case 'ready':
|
|
395
|
+
return (
|
|
396
|
+
<div className="divide-y divide-border/60">
|
|
397
|
+
{section.state.rows.map((row) => (
|
|
398
|
+
<EndpointDoc
|
|
399
|
+
key={row.key}
|
|
400
|
+
endpoint={row.endpoint}
|
|
401
|
+
isLoadedInPlayground={row.isLoaded}
|
|
402
|
+
onTryIt={() => onTryEndpoint(row.endpoint)}
|
|
403
|
+
schemaId={row.schemaId}
|
|
404
|
+
/>
|
|
405
|
+
))}
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -18,10 +18,14 @@ interface EndpointDocProps {
|
|
|
18
18
|
/** Is this endpoint currently loaded in the sticky playground? */
|
|
19
19
|
isLoadedInPlayground: boolean;
|
|
20
20
|
onTryIt: () => void;
|
|
21
|
+
/** Scoping prefix for the anchor, so endpoints from different schemas
|
|
22
|
+
* don't collide on a single page. Falls back to ``endpoint.schemaId``. */
|
|
23
|
+
schemaId?: string | null;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
export function EndpointDoc({ endpoint, isLoadedInPlayground, onTryIt }: EndpointDocProps) {
|
|
24
|
-
const
|
|
26
|
+
export function EndpointDoc({ endpoint, isLoadedInPlayground, onTryIt, schemaId }: EndpointDocProps) {
|
|
27
|
+
const scopedSchemaId = schemaId ?? endpoint.schemaId ?? null;
|
|
28
|
+
const anchor = endpointAnchor(endpoint, scopedSchemaId);
|
|
25
29
|
const pathParams = endpoint.parameters?.filter((p) => endpoint.path.includes(`{${p.name}}`)) ?? [];
|
|
26
30
|
const queryParams = endpoint.parameters?.filter((p) => !endpoint.path.includes(`{${p.name}}`)) ?? [];
|
|
27
31
|
|
|
@@ -43,6 +47,7 @@ export function EndpointDoc({ endpoint, isLoadedInPlayground, onTryIt }: Endpoin
|
|
|
43
47
|
<section
|
|
44
48
|
id={anchor}
|
|
45
49
|
data-endpoint-anchor={anchor}
|
|
50
|
+
data-schema-id={scopedSchemaId ?? ''}
|
|
46
51
|
className="scroll-mt-24 py-10 first:pt-0"
|
|
47
52
|
>
|
|
48
53
|
<header className="space-y-4">
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
DropdownMenuSeparator,
|
|
13
13
|
DropdownMenuTrigger,
|
|
14
14
|
} from '@djangocfg/ui-core/components';
|
|
15
|
+
import { toast } from '@djangocfg/ui-core/hooks';
|
|
15
16
|
|
|
16
17
|
import type { ApiEndpoint, OpenApiSchema } from '../../types';
|
|
17
18
|
import {
|
|
@@ -44,6 +45,11 @@ interface SchemaCopyMenuProps {
|
|
|
44
45
|
/** Resolved base URL that gets embedded into the copy so the AI
|
|
45
46
|
* receives working URLs, not the ones originally in ``schema.servers``. */
|
|
46
47
|
baseUrl?: string;
|
|
48
|
+
/** Trigger appearance.
|
|
49
|
+
* - ``button`` (default) — labelled pill with icon + chevron.
|
|
50
|
+
* - ``icon`` — square ghost button, used in tight spots like the
|
|
51
|
+
* sidebar header where there is no room for "Copy for AI". */
|
|
52
|
+
variant?: 'button' | 'icon';
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
/**
|
|
@@ -52,7 +58,7 @@ interface SchemaCopyMenuProps {
|
|
|
52
58
|
* dereferencing + stringifying a large schema can be non-trivial — sizes
|
|
53
59
|
* are displayed after the first successful copy, via a tiny cache.
|
|
54
60
|
*/
|
|
55
|
-
export function SchemaCopyMenu({ schema, endpoints, baseUrl }: SchemaCopyMenuProps) {
|
|
61
|
+
export function SchemaCopyMenu({ schema, endpoints, baseUrl, variant = 'button' }: SchemaCopyMenuProps) {
|
|
56
62
|
const [sizeCache, setSizeCache] = useState<Partial<Record<Flavour, string>>>({});
|
|
57
63
|
const [justCopied, setJustCopied] = useState<Flavour | null>(null);
|
|
58
64
|
const [open, setOpen] = useState(false);
|
|
@@ -73,14 +79,18 @@ export function SchemaCopyMenu({ schema, endpoints, baseUrl }: SchemaCopyMenuPro
|
|
|
73
79
|
async (flavour: Flavour) => {
|
|
74
80
|
if (!isReady) return;
|
|
75
81
|
const text = build(flavour);
|
|
82
|
+
const label = FLAVOUR_LABELS[flavour].title;
|
|
76
83
|
try {
|
|
77
84
|
await navigator.clipboard.writeText(text);
|
|
78
|
-
|
|
85
|
+
const size = formatBytes(text);
|
|
86
|
+
setSizeCache((prev) => ({ ...prev, [flavour]: size }));
|
|
79
87
|
setJustCopied(flavour);
|
|
80
88
|
setTimeout(() => setJustCopied(null), 1500);
|
|
81
89
|
setOpen(false);
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
toast.success(`Copied ${label}`, { description: size });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const message = err instanceof Error ? err.message : 'Clipboard permission denied';
|
|
93
|
+
toast.error('Copy failed', { description: message });
|
|
84
94
|
}
|
|
85
95
|
},
|
|
86
96
|
[build, isReady],
|
|
@@ -91,11 +101,24 @@ export function SchemaCopyMenu({ schema, endpoints, baseUrl }: SchemaCopyMenuPro
|
|
|
91
101
|
return (
|
|
92
102
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
93
103
|
<DropdownMenuTrigger asChild>
|
|
94
|
-
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
104
|
+
{variant === 'icon' ? (
|
|
105
|
+
<Button
|
|
106
|
+
variant="ghost"
|
|
107
|
+
size="icon"
|
|
108
|
+
className="h-7 w-7 shrink-0"
|
|
109
|
+
disabled={!isReady}
|
|
110
|
+
title="Copy schema for AI"
|
|
111
|
+
aria-label="Copy schema for AI"
|
|
112
|
+
>
|
|
113
|
+
<Sparkles className="h-3.5 w-3.5" />
|
|
114
|
+
</Button>
|
|
115
|
+
) : (
|
|
116
|
+
<Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs" disabled={!isReady}>
|
|
117
|
+
<Sparkles className="h-3 w-3" />
|
|
118
|
+
Copy for AI
|
|
119
|
+
<ChevronDown className="h-3 w-3 opacity-60" />
|
|
120
|
+
</Button>
|
|
121
|
+
)}
|
|
99
122
|
</DropdownMenuTrigger>
|
|
100
123
|
<DropdownMenuContent align="end" className="w-72">
|
|
101
124
|
<DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-muted-foreground/70">
|