@djangocfg/ui-tools 2.1.284 → 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.
- 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 +4 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -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,101 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { SidePanel } from '@djangocfg/ui-core/components';
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
|
|
8
|
+
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
9
|
+
import { RequestPanel } from '../shared/RequestPanel';
|
|
10
|
+
import { ResponsePanel } from '../shared/ResponsePanel';
|
|
11
|
+
import { SendButton } from '../shared/SendButton';
|
|
12
|
+
import { MethodBadge, Panel, relativePath } from '../shared/ui';
|
|
13
|
+
|
|
14
|
+
interface SlideInPlaygroundProps {
|
|
15
|
+
open: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Width when only Request is visible vs. when Response is also rendered.
|
|
20
|
+
// ``clamp(min, preferred, max)`` lets the panel scale with the viewport
|
|
21
|
+
// instead of sitting at a fixed size that looks tiny on ultra-wide or
|
|
22
|
+
// cramped on laptops.
|
|
23
|
+
//
|
|
24
|
+
// Narrow (Request only) — form content, no need to ever exceed ~480px.
|
|
25
|
+
// 1280 viewport → 384 | 1440 → 432 | 1920 → 480 (cap) | 2560 → 480 (cap)
|
|
26
|
+
//
|
|
27
|
+
// Wide (Request + Response) — response can be huge JSON/HTML; give it room.
|
|
28
|
+
// 1280 → 768 | 1440 → 864 | 1920 → 1152 | 2560 → 1280 (cap) | 3840 → 1280 (cap)
|
|
29
|
+
//
|
|
30
|
+
// Transition between the two values is animated by SidePanel.Content
|
|
31
|
+
// (both ``transform`` and ``width`` listed there). clamp() interpolates
|
|
32
|
+
// smoothly during that transition, no extra plumbing needed.
|
|
33
|
+
const WIDTH_NARROW = 'clamp(380px, 30vw, 480px)';
|
|
34
|
+
const WIDTH_WIDE = 'clamp(720px, 60vw, 1280px)';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Right-side slide-in playground. Two-column layout appears once the
|
|
38
|
+
* user has a response in hand — before that, only the Request form is
|
|
39
|
+
* shown and the panel stays narrow so docs have room to breathe.
|
|
40
|
+
*
|
|
41
|
+
* State transitions:
|
|
42
|
+
* - no endpoint ⇒ panel not rendered
|
|
43
|
+
* - endpoint, no resp ⇒ narrow (Request only)
|
|
44
|
+
* - endpoint + loading ⇒ wide (Request + Response spinner)
|
|
45
|
+
* - endpoint + resp ⇒ wide (Request + Response)
|
|
46
|
+
*
|
|
47
|
+
* Selecting a different endpoint clears ``response`` in the reducer,
|
|
48
|
+
* so the panel smoothly collapses back to narrow.
|
|
49
|
+
*/
|
|
50
|
+
export function SlideInPlayground({ open, onClose }: SlideInPlaygroundProps) {
|
|
51
|
+
const { state } = usePlaygroundContext();
|
|
52
|
+
const ep = state.selectedEndpoint;
|
|
53
|
+
const showResponse = state.response !== null || state.loading;
|
|
54
|
+
const width = showResponse ? WIDTH_WIDE : WIDTH_NARROW;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<SidePanel open={open} onOpenChange={(v) => !v && onClose()} side="right">
|
|
58
|
+
<SidePanel.Content width={width} className="max-w-[95vw]">
|
|
59
|
+
<SidePanel.Header>
|
|
60
|
+
<SidePanel.Title>Playground</SidePanel.Title>
|
|
61
|
+
{ep && (
|
|
62
|
+
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
63
|
+
<MethodBadge method={ep.method} />
|
|
64
|
+
<code className="font-mono text-[11px] text-muted-foreground truncate">
|
|
65
|
+
{relativePath(ep.path)}
|
|
66
|
+
</code>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
<SidePanel.Close className="ml-auto" />
|
|
70
|
+
</SidePanel.Header>
|
|
71
|
+
|
|
72
|
+
{/* Body: 1 or 2 columns depending on whether we have a
|
|
73
|
+
response to show. ``divide-x`` gives a 1px seam
|
|
74
|
+
between the panes so they read as distinct surfaces. */}
|
|
75
|
+
<SidePanel.Body
|
|
76
|
+
className={cn(
|
|
77
|
+
'overflow-hidden grid divide-x transition-[grid-template-columns] duration-250',
|
|
78
|
+
showResponse
|
|
79
|
+
? 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)]'
|
|
80
|
+
: 'grid-cols-1',
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
<Panel>
|
|
84
|
+
<RequestPanel />
|
|
85
|
+
</Panel>
|
|
86
|
+
{showResponse && (
|
|
87
|
+
<Panel>
|
|
88
|
+
<ResponsePanel />
|
|
89
|
+
</Panel>
|
|
90
|
+
)}
|
|
91
|
+
</SidePanel.Body>
|
|
92
|
+
|
|
93
|
+
{ep && (
|
|
94
|
+
<SidePanel.Footer className="px-4 py-3">
|
|
95
|
+
<SendButton />
|
|
96
|
+
</SidePanel.Footer>
|
|
97
|
+
)}
|
|
98
|
+
</SidePanel.Content>
|
|
99
|
+
</SidePanel>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ResponsiveSheet,
|
|
7
|
+
ResponsiveSheetContent,
|
|
8
|
+
ResponsiveSheetHeader,
|
|
9
|
+
ResponsiveSheetTitle,
|
|
10
|
+
} from '@djangocfg/ui-core/components';
|
|
11
|
+
|
|
12
|
+
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
13
|
+
import { RequestPanel } from '../shared/RequestPanel';
|
|
14
|
+
import { ResponsePanel } from '../shared/ResponsePanel';
|
|
15
|
+
import { SendButton } from '../shared/SendButton';
|
|
16
|
+
|
|
17
|
+
interface TryItSheetProps {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onOpenChange: (open: boolean) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mobile/tablet fallback: the slide-in playground doesn't fit, so we
|
|
24
|
+
* open Request + Response inside a ResponsiveSheet (bottom drawer on
|
|
25
|
+
* mobile, side sheet on desktop). Response stays hidden until a
|
|
26
|
+
* request has been sent — matching the desktop ``SlideInPlayground``
|
|
27
|
+
* behaviour.
|
|
28
|
+
*/
|
|
29
|
+
export function TryItSheet({ open, onOpenChange }: TryItSheetProps) {
|
|
30
|
+
const { state } = usePlaygroundContext();
|
|
31
|
+
const showResponse = state.response !== null || state.loading;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<ResponsiveSheet open={open} onOpenChange={onOpenChange}>
|
|
35
|
+
<ResponsiveSheetContent className="sm:max-w-xl flex flex-col h-full p-0">
|
|
36
|
+
<ResponsiveSheetHeader className="px-4 py-3 border-b shrink-0">
|
|
37
|
+
<ResponsiveSheetTitle className="text-sm">Playground</ResponsiveSheetTitle>
|
|
38
|
+
</ResponsiveSheetHeader>
|
|
39
|
+
<div className="flex-1 min-h-0 flex flex-col divide-y">
|
|
40
|
+
<div className={showResponse ? 'flex-1 min-h-0 flex flex-col' : 'flex-1 min-h-0 flex flex-col'}>
|
|
41
|
+
<RequestPanel />
|
|
42
|
+
</div>
|
|
43
|
+
{showResponse && (
|
|
44
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
45
|
+
<ResponsePanel />
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
{state.selectedEndpoint && (
|
|
50
|
+
<div className="shrink-0 border-t px-4 py-3 bg-background/95 backdrop-blur-sm">
|
|
51
|
+
<SendButton />
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</ResponsiveSheetContent>
|
|
55
|
+
</ResponsiveSheet>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ApiEndpoint } from '../../types';
|
|
2
|
+
|
|
3
|
+
export function endpointAnchor(ep: Pick<ApiEndpoint, 'method' | 'path'>): string {
|
|
4
|
+
const slug = ep.path
|
|
5
|
+
.replace(/^https?:\/\/[^/]+/, '')
|
|
6
|
+
.replace(/[{}]/g, '')
|
|
7
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
8
|
+
.replace(/^-+|-+$/g, '')
|
|
9
|
+
.toLowerCase();
|
|
10
|
+
return `ep-${ep.method.toLowerCase()}-${slug}`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared grouping / sort for the docs layout.
|
|
3
|
+
*
|
|
4
|
+
* Sidebar and the docs longread MUST use the same order — otherwise
|
|
5
|
+
* scrollspy highlights jump around as the user scrolls (the sidebar's
|
|
6
|
+
* ordered list doesn't match the visual order of sections in the docs).
|
|
7
|
+
* This module is the single source of truth for that ordering.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ApiEndpoint } from '../../types';
|
|
11
|
+
import { longestCommonPrefix } from './sidebarLabel';
|
|
12
|
+
|
|
13
|
+
export type EndpointGroup = {
|
|
14
|
+
category: string;
|
|
15
|
+
endpoints: ApiEndpoint[];
|
|
16
|
+
/** Longest ``/``-aligned prefix shared by every endpoint in this
|
|
17
|
+
* group. Used by the sidebar to strip the redundant group prefix
|
|
18
|
+
* from fallback labels. */
|
|
19
|
+
commonPrefix: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const METHOD_ORDER: Record<string, number> = {
|
|
23
|
+
GET: 0,
|
|
24
|
+
POST: 1,
|
|
25
|
+
PUT: 2,
|
|
26
|
+
PATCH: 3,
|
|
27
|
+
DELETE: 4,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Stable, deterministic ordering so two different renders with the
|
|
32
|
+
* same endpoint list always produce the same visual sequence.
|
|
33
|
+
*
|
|
34
|
+
* Groups: alphabetical by tag/category, with ``Other`` pinned to the
|
|
35
|
+
* bottom (spec-less endpoints should not steal the top slot).
|
|
36
|
+
*
|
|
37
|
+
* Within a group: endpoints sorted by path first (so related resources
|
|
38
|
+
* cluster), then by HTTP method (read → write → delete).
|
|
39
|
+
*/
|
|
40
|
+
export function groupEndpoints(list: ApiEndpoint[]): EndpointGroup[] {
|
|
41
|
+
const map = new Map<string, ApiEndpoint[]>();
|
|
42
|
+
for (const ep of list) {
|
|
43
|
+
const arr = map.get(ep.category) ?? [];
|
|
44
|
+
arr.push(ep);
|
|
45
|
+
map.set(ep.category, arr);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const groups: EndpointGroup[] = Array.from(map.entries()).map(([category, endpoints]) => ({
|
|
49
|
+
category,
|
|
50
|
+
endpoints: [...endpoints].sort((a, b) => {
|
|
51
|
+
const byPath = a.path.localeCompare(b.path);
|
|
52
|
+
if (byPath !== 0) return byPath;
|
|
53
|
+
return (METHOD_ORDER[a.method] ?? 99) - (METHOD_ORDER[b.method] ?? 99);
|
|
54
|
+
}),
|
|
55
|
+
commonPrefix: longestCommonPrefix(endpoints.map((e) => e.path)),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
groups.sort((a, b) => {
|
|
59
|
+
if (a.category === 'Other') return 1;
|
|
60
|
+
if (b.category === 'Other') return -1;
|
|
61
|
+
return a.category.localeCompare(b.category);
|
|
62
|
+
});
|
|
63
|
+
return groups;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Flatten grouped endpoints back into a linear list that preserves
|
|
67
|
+
* group order + within-group order. This is the canonical order for
|
|
68
|
+
* both the sidebar and the docs longread. */
|
|
69
|
+
export function flattenGrouped(groups: EndpointGroup[]): ApiEndpoint[] {
|
|
70
|
+
return groups.flatMap((g) => g.endpoints);
|
|
71
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { Skeleton, TooltipProvider } from '@djangocfg/ui-core/components';
|
|
6
|
+
import { useMediaQuery } from '@djangocfg/ui-core/hooks';
|
|
7
|
+
|
|
8
|
+
import useOpenApiSchema from '../../hooks/useOpenApiSchema';
|
|
9
|
+
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
10
|
+
import type { ApiEndpoint } from '../../types';
|
|
11
|
+
import { EndpointDraftSync } from '../shared/EndpointDraftSync';
|
|
12
|
+
import { DocsSidebar } from './Sidebar';
|
|
13
|
+
import { DocsView, type DocsViewHandle } from './DocsView';
|
|
14
|
+
import { SlideInPlayground } from './SlideInPlayground';
|
|
15
|
+
import { TryItSheet } from './TryItSheet';
|
|
16
|
+
|
|
17
|
+
// ─── Root ─────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export const DocsLayout: React.FC = () => {
|
|
20
|
+
const { state, config, setSelectedEndpoint } = usePlaygroundContext();
|
|
21
|
+
// The docs layout has a sidebar + docs column that already eat ~260px
|
|
22
|
+
// before the slide-in opens. Below 1024px the slide-in (min 720 wide)
|
|
23
|
+
// leaves docs with <250px — unreadable — so we fall back to the
|
|
24
|
+
// mobile-style ``TryItSheet`` on those viewports.
|
|
25
|
+
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
|
26
|
+
const isMobile = !isDesktop;
|
|
27
|
+
const {
|
|
28
|
+
endpoints,
|
|
29
|
+
schemaInfo,
|
|
30
|
+
rawSchema,
|
|
31
|
+
resolvedBaseUrl,
|
|
32
|
+
loading,
|
|
33
|
+
error,
|
|
34
|
+
schemas,
|
|
35
|
+
currentSchema,
|
|
36
|
+
setCurrentSchema,
|
|
37
|
+
} = useOpenApiSchema({
|
|
38
|
+
schemas: config.schemas,
|
|
39
|
+
defaultSchemaId: config.defaultSchemaId,
|
|
40
|
+
baseUrl: config.baseUrl,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const [activeAnchor, setActiveAnchor] = useState<string | null>(null);
|
|
44
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
45
|
+
const docsRef = useRef<DocsViewHandle | null>(null);
|
|
46
|
+
|
|
47
|
+
// Desktop slide-in is driven directly by ``selectedEndpoint``. Keeping a
|
|
48
|
+
// separate open-state would mean two sources of truth for the same
|
|
49
|
+
// semantic — "which endpoint is loaded into the playground".
|
|
50
|
+
const slideOpen = !isMobile && state.selectedEndpoint !== null;
|
|
51
|
+
|
|
52
|
+
const handleTry = useCallback(
|
|
53
|
+
(ep: ApiEndpoint) => {
|
|
54
|
+
setSelectedEndpoint(ep);
|
|
55
|
+
if (isMobile) setSheetOpen(true);
|
|
56
|
+
},
|
|
57
|
+
[isMobile, setSelectedEndpoint],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const handleCloseSlide = useCallback(() => {
|
|
61
|
+
setSelectedEndpoint(null);
|
|
62
|
+
}, [setSelectedEndpoint]);
|
|
63
|
+
|
|
64
|
+
const handleNavigate = useCallback((anchor: string) => {
|
|
65
|
+
docsRef.current?.scrollToAnchor(anchor);
|
|
66
|
+
}, []);
|
|
67
|
+
// Esc handling lives inside SidePanel itself — no duplicate listener.
|
|
68
|
+
|
|
69
|
+
if (loading) {
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className="grid grid-cols-[260px_1fr] min-h-0 overflow-hidden"
|
|
73
|
+
style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
|
|
74
|
+
>
|
|
75
|
+
<div className="border-r p-3 space-y-1.5">
|
|
76
|
+
{Array.from({ length: 12 }).map((_, i) => (
|
|
77
|
+
<Skeleton key={i} className="h-8 w-full rounded" />
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
<div className="p-8 space-y-4">
|
|
81
|
+
<Skeleton className="h-8 w-1/2" />
|
|
82
|
+
<Skeleton className="h-4 w-full" />
|
|
83
|
+
<Skeleton className="h-4 w-3/4" />
|
|
84
|
+
<div className="mt-8 space-y-6">
|
|
85
|
+
{Array.from({ length: 3 }).map((_, i) => (
|
|
86
|
+
<div key={i} className="space-y-3">
|
|
87
|
+
<Skeleton className="h-6 w-1/3" />
|
|
88
|
+
<Skeleton className="h-20 w-full" />
|
|
89
|
+
</div>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (error) {
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
className="flex items-center justify-center p-8"
|
|
101
|
+
style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
|
|
102
|
+
>
|
|
103
|
+
<p className="text-sm text-destructive">Failed to load schema: {error}</p>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Mobile/tablet: sidebar + docs only, playground opens in sheet.
|
|
109
|
+
if (isMobile) {
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
className="flex flex-col overflow-hidden"
|
|
113
|
+
style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
|
|
114
|
+
>
|
|
115
|
+
<EndpointDraftSync schemaId={currentSchema?.id ?? null} />
|
|
116
|
+
<DocsView
|
|
117
|
+
ref={docsRef}
|
|
118
|
+
info={schemaInfo}
|
|
119
|
+
rawSchema={rawSchema}
|
|
120
|
+
resolvedBaseUrl={resolvedBaseUrl}
|
|
121
|
+
endpoints={endpoints}
|
|
122
|
+
selectedVersion={state.selectedVersion}
|
|
123
|
+
loadedEndpoint={state.selectedEndpoint}
|
|
124
|
+
onTryEndpoint={handleTry}
|
|
125
|
+
onActiveChange={setActiveAnchor}
|
|
126
|
+
/>
|
|
127
|
+
<TryItSheet open={sheetOpen} onOpenChange={setSheetOpen} />
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<TooltipProvider delayDuration={350}>
|
|
134
|
+
<div
|
|
135
|
+
className="grid grid-cols-[260px_minmax(0,1fr)] min-h-0 overflow-hidden"
|
|
136
|
+
style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
|
|
137
|
+
>
|
|
138
|
+
<EndpointDraftSync schemaId={currentSchema?.id ?? null} />
|
|
139
|
+
<DocsSidebar
|
|
140
|
+
info={schemaInfo}
|
|
141
|
+
endpoints={endpoints}
|
|
142
|
+
schemas={schemas}
|
|
143
|
+
currentSchemaId={currentSchema?.id ?? null}
|
|
144
|
+
onSchemaChange={setCurrentSchema}
|
|
145
|
+
activeEndpointId={activeAnchor}
|
|
146
|
+
selectedVersion={state.selectedVersion}
|
|
147
|
+
onNavigate={handleNavigate}
|
|
148
|
+
/>
|
|
149
|
+
<DocsView
|
|
150
|
+
ref={docsRef}
|
|
151
|
+
info={schemaInfo}
|
|
152
|
+
rawSchema={rawSchema}
|
|
153
|
+
resolvedBaseUrl={resolvedBaseUrl}
|
|
154
|
+
endpoints={endpoints}
|
|
155
|
+
selectedVersion={state.selectedVersion}
|
|
156
|
+
loadedEndpoint={state.selectedEndpoint}
|
|
157
|
+
onTryEndpoint={handleTry}
|
|
158
|
+
onActiveChange={setActiveAnchor}
|
|
159
|
+
/>
|
|
160
|
+
{/* SidePanel renders into <body> via portal, so it floats
|
|
161
|
+
above the whole layout (sidebar + navbar included). */}
|
|
162
|
+
<SlideInPlayground open={slideOpen} onClose={handleCloseSlide} />
|
|
163
|
+
</div>
|
|
164
|
+
</TooltipProvider>
|
|
165
|
+
);
|
|
166
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flatten a JSON Schema node into a flat list of (name, type, required,
|
|
3
|
+
* description) rows, ready to feed into the existing parameter table.
|
|
4
|
+
*
|
|
5
|
+
* Rules:
|
|
6
|
+
* - ``type: object`` → one row per property.
|
|
7
|
+
* - ``type: array`` → recurse into items; prefix each row with ``[].``
|
|
8
|
+
* so the caller sees "[].username string" etc.
|
|
9
|
+
* - Nested objects → dot-joined path: ``category.name``. We expand
|
|
10
|
+
* one level; deeper graphs get a single summary
|
|
11
|
+
* row ("category.* (object)") to keep the table
|
|
12
|
+
* readable.
|
|
13
|
+
*
|
|
14
|
+
* This is a presentation helper — not a full form generator. It powers
|
|
15
|
+
* the read-only fields table shown in the docs longread.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type JsonSchemaNode = Record<string, unknown> & {
|
|
19
|
+
type?: string;
|
|
20
|
+
properties?: Record<string, JsonSchemaNode>;
|
|
21
|
+
required?: string[];
|
|
22
|
+
items?: JsonSchemaNode;
|
|
23
|
+
enum?: unknown[];
|
|
24
|
+
description?: string;
|
|
25
|
+
format?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface SchemaField {
|
|
29
|
+
name: string;
|
|
30
|
+
type: string;
|
|
31
|
+
required: boolean;
|
|
32
|
+
description?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MAX_DEPTH = 2;
|
|
36
|
+
|
|
37
|
+
function describeType(node: JsonSchemaNode): string {
|
|
38
|
+
if (!node.type && node.properties) return 'object';
|
|
39
|
+
const base = node.type || 'any';
|
|
40
|
+
if (base === 'array') {
|
|
41
|
+
const itemType = node.items ? describeType(node.items) : 'any';
|
|
42
|
+
return `array<${itemType}>`;
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(node.enum) && node.enum.length > 0) {
|
|
45
|
+
return `${base} enum`;
|
|
46
|
+
}
|
|
47
|
+
if (node.format) return `${base} (${node.format})`;
|
|
48
|
+
return base;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function schemaToFields(
|
|
52
|
+
schema: JsonSchemaNode | undefined,
|
|
53
|
+
prefix = '',
|
|
54
|
+
depth = 0,
|
|
55
|
+
): SchemaField[] {
|
|
56
|
+
if (!schema || depth > MAX_DEPTH) return [];
|
|
57
|
+
|
|
58
|
+
// Unwrap arrays: show the inner item's fields, prefixed with ``[]``.
|
|
59
|
+
if (schema.type === 'array') {
|
|
60
|
+
if (!schema.items) {
|
|
61
|
+
return [{ name: prefix || '[]', type: 'array', required: false }];
|
|
62
|
+
}
|
|
63
|
+
const inner = schemaToFields(schema.items, prefix ? `${prefix}[]` : '[]', depth);
|
|
64
|
+
if (inner.length === 0) {
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
name: prefix || '[]',
|
|
68
|
+
type: describeType(schema),
|
|
69
|
+
required: false,
|
|
70
|
+
description: schema.description,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
return inner;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Primitives and unknown — single row.
|
|
78
|
+
if (schema.type !== 'object' && !schema.properties) {
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
name: prefix || '(body)',
|
|
82
|
+
type: describeType(schema),
|
|
83
|
+
required: false,
|
|
84
|
+
description: schema.description,
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Objects — one row per property, recurse for nested objects/arrays.
|
|
90
|
+
const required = new Set(schema.required ?? []);
|
|
91
|
+
const rows: SchemaField[] = [];
|
|
92
|
+
const props = schema.properties ?? {};
|
|
93
|
+
for (const [key, node] of Object.entries(props)) {
|
|
94
|
+
const fullName = prefix ? `${prefix}.${key}` : key;
|
|
95
|
+
const isRequired = required.has(key);
|
|
96
|
+
|
|
97
|
+
const isNestedExpandable =
|
|
98
|
+
(node.type === 'object' && node.properties) ||
|
|
99
|
+
(node.type === 'array' && node.items);
|
|
100
|
+
|
|
101
|
+
if (isNestedExpandable && depth < MAX_DEPTH) {
|
|
102
|
+
// Leaf summary row for the parent itself (so the user sees
|
|
103
|
+
// its description) + recurse for inner fields.
|
|
104
|
+
rows.push({
|
|
105
|
+
name: fullName,
|
|
106
|
+
type: describeType(node),
|
|
107
|
+
required: isRequired,
|
|
108
|
+
description: node.description,
|
|
109
|
+
});
|
|
110
|
+
rows.push(...schemaToFields(node, fullName, depth + 1));
|
|
111
|
+
} else {
|
|
112
|
+
rows.push({
|
|
113
|
+
name: fullName,
|
|
114
|
+
type: describeType(node),
|
|
115
|
+
required: isRequired,
|
|
116
|
+
description: node.description,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return rows;
|
|
121
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ApiEndpoint } from '../../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Given a list of full endpoint paths, return the longest ``/``-aligned
|
|
5
|
+
* prefix they all share. Used to strip the redundant group prefix
|
|
6
|
+
* (``/api/v3/pet``) from sidebar labels so the meaningful tail is visible.
|
|
7
|
+
*
|
|
8
|
+
* Works on full URLs too — if every path begins with the same origin,
|
|
9
|
+
* the origin is part of the common prefix and gets stripped.
|
|
10
|
+
*/
|
|
11
|
+
export function longestCommonPrefix(paths: string[]): string {
|
|
12
|
+
if (paths.length === 0) return '';
|
|
13
|
+
if (paths.length === 1) return '';
|
|
14
|
+
|
|
15
|
+
const segments = paths.map((p) => p.split('/'));
|
|
16
|
+
const minLen = Math.min(...segments.map((s) => s.length));
|
|
17
|
+
|
|
18
|
+
const shared: string[] = [];
|
|
19
|
+
for (let i = 0; i < minLen; i++) {
|
|
20
|
+
const first = segments[0]![i];
|
|
21
|
+
if (segments.every((s) => s[i] === first)) {
|
|
22
|
+
shared.push(first!);
|
|
23
|
+
} else {
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Don't strip everything — we always want at least a leading "/" or
|
|
28
|
+
// the method on the visible side. If the group has one endpoint,
|
|
29
|
+
// the caller guards with paths.length check already.
|
|
30
|
+
const joined = shared.join('/');
|
|
31
|
+
// Trim trailing slash so the tail is clean (``/foo`` not ``foo``).
|
|
32
|
+
return joined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compute the label to show in the sidebar for a given endpoint.
|
|
37
|
+
*
|
|
38
|
+
* Priority:
|
|
39
|
+
* 1. ``ep.summary`` — human-readable, from OpenAPI ``operation.summary``.
|
|
40
|
+
* 2. The tail of ``ep.path`` after stripping the group's common prefix.
|
|
41
|
+
* 3. Full ``ep.path`` if the group has a single endpoint (no prefix to strip).
|
|
42
|
+
*/
|
|
43
|
+
export function sidebarLabel(ep: ApiEndpoint, groupCommonPrefix: string): string {
|
|
44
|
+
if (ep.summary) return ep.summary;
|
|
45
|
+
|
|
46
|
+
if (groupCommonPrefix && ep.path.startsWith(groupCommonPrefix)) {
|
|
47
|
+
const tail = ep.path.slice(groupCommonPrefix.length) || '/';
|
|
48
|
+
return tail;
|
|
49
|
+
}
|
|
50
|
+
return relativePath(ep.path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function relativePath(full: string): string {
|
|
54
|
+
try { return new URL(full).pathname; } catch { return full; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Tooltip text: always the definitive ``METHOD relative/path``. */
|
|
58
|
+
export function sidebarTooltip(ep: ApiEndpoint): string {
|
|
59
|
+
return `${ep.method} ${relativePath(ep.path)}`;
|
|
60
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* OpenapiViewer — component exports.
|
|
3
|
+
* Only ``DocsLayout`` remains; the legacy 3-column ``PlaygroundLayout``
|
|
4
|
+
* has been removed. Shared panels live under ``./shared`` and are
|
|
5
|
+
* consumed by the docs layout + its mobile sheet.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
|
-
export {
|
|
8
|
+
export { DocsLayout } from './DocsLayout';
|