@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,211 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Search } from 'lucide-react';
|
|
4
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Combobox,
|
|
8
|
+
Input,
|
|
9
|
+
Tooltip,
|
|
10
|
+
TooltipContent,
|
|
11
|
+
TooltipTrigger,
|
|
12
|
+
} from '@djangocfg/ui-core/components';
|
|
13
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
|
+
|
|
15
|
+
import type { ApiEndpoint, OpenApiInfo, SchemaSource } from '../../types';
|
|
16
|
+
import { deduplicateEndpoints } from '../../utils/versionManager';
|
|
17
|
+
import { MethodBadge, ScrollArea } from '../shared/ui';
|
|
18
|
+
import { endpointAnchor } from './anchor';
|
|
19
|
+
import { longestCommonPrefix, sidebarLabel, sidebarTooltip } from './sidebarLabel';
|
|
20
|
+
|
|
21
|
+
type Group = {
|
|
22
|
+
category: string;
|
|
23
|
+
endpoints: ApiEndpoint[];
|
|
24
|
+
/** Longest ``/``-aligned prefix shared by every endpoint in this group.
|
|
25
|
+
* Stripped from labels that fall back to showing the path. */
|
|
26
|
+
commonPrefix: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const METHOD_ORDER: Record<string, number> = {
|
|
30
|
+
GET: 0, POST: 1, PUT: 2, PATCH: 3, DELETE: 4,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function groupEndpoints(list: ApiEndpoint[]): Group[] {
|
|
34
|
+
const map = new Map<string, ApiEndpoint[]>();
|
|
35
|
+
for (const ep of list) {
|
|
36
|
+
const arr = map.get(ep.category) ?? [];
|
|
37
|
+
arr.push(ep);
|
|
38
|
+
map.set(ep.category, arr);
|
|
39
|
+
}
|
|
40
|
+
const groups: Group[] = Array.from(map.entries()).map(([category, endpoints]) => ({
|
|
41
|
+
category,
|
|
42
|
+
endpoints: [...endpoints].sort((a, b) => {
|
|
43
|
+
const byPath = a.path.localeCompare(b.path);
|
|
44
|
+
if (byPath !== 0) return byPath;
|
|
45
|
+
return (METHOD_ORDER[a.method] ?? 99) - (METHOD_ORDER[b.method] ?? 99);
|
|
46
|
+
}),
|
|
47
|
+
commonPrefix: longestCommonPrefix(endpoints.map((e) => e.path)),
|
|
48
|
+
}));
|
|
49
|
+
// Alphabetical, but "Other" sinks to the bottom.
|
|
50
|
+
groups.sort((a, b) => {
|
|
51
|
+
if (a.category === 'Other') return 1;
|
|
52
|
+
if (b.category === 'Other') return -1;
|
|
53
|
+
return a.category.localeCompare(b.category);
|
|
54
|
+
});
|
|
55
|
+
return groups;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DocsSidebarProps {
|
|
59
|
+
info: OpenApiInfo | null;
|
|
60
|
+
endpoints: ApiEndpoint[];
|
|
61
|
+
schemas: SchemaSource[];
|
|
62
|
+
currentSchemaId: string | null;
|
|
63
|
+
onSchemaChange: (id: string) => void;
|
|
64
|
+
activeEndpointId: string | null;
|
|
65
|
+
selectedVersion: string;
|
|
66
|
+
onNavigate: (anchor: string) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function DocsSidebar({
|
|
70
|
+
info,
|
|
71
|
+
endpoints,
|
|
72
|
+
schemas,
|
|
73
|
+
currentSchemaId,
|
|
74
|
+
onSchemaChange,
|
|
75
|
+
activeEndpointId,
|
|
76
|
+
selectedVersion,
|
|
77
|
+
onNavigate,
|
|
78
|
+
}: DocsSidebarProps) {
|
|
79
|
+
const [search, setSearch] = useState('');
|
|
80
|
+
const [debounced, setDebounced] = useState('');
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const id = setTimeout(() => setDebounced(search), 120);
|
|
84
|
+
return () => clearTimeout(id);
|
|
85
|
+
}, [search]);
|
|
86
|
+
|
|
87
|
+
const filteredGroups = useMemo(() => {
|
|
88
|
+
let list = deduplicateEndpoints(endpoints, selectedVersion);
|
|
89
|
+
if (debounced) {
|
|
90
|
+
const q = debounced.toLowerCase();
|
|
91
|
+
list = list.filter((e) =>
|
|
92
|
+
e.summary.toLowerCase().includes(q) ||
|
|
93
|
+
e.name.toLowerCase().includes(q) ||
|
|
94
|
+
e.description.toLowerCase().includes(q) ||
|
|
95
|
+
e.path.toLowerCase().includes(q),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return groupEndpoints(list);
|
|
99
|
+
}, [endpoints, debounced, selectedVersion]);
|
|
100
|
+
|
|
101
|
+
const schemaOptions = useMemo(
|
|
102
|
+
() => schemas.map((s) => ({ value: s.id, label: s.name })),
|
|
103
|
+
[schemas],
|
|
104
|
+
);
|
|
105
|
+
const hasMultipleSchemas = schemas.length > 1;
|
|
106
|
+
const apiTitle = info?.title ?? 'API Reference';
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<aside className="flex flex-col min-h-0 border-r bg-muted/10">
|
|
110
|
+
{/* Brand row */}
|
|
111
|
+
<div className="shrink-0 border-b px-4 h-12 flex items-center gap-2">
|
|
112
|
+
<span className="text-[13px] font-semibold text-foreground truncate">
|
|
113
|
+
{apiTitle}
|
|
114
|
+
</span>
|
|
115
|
+
{info?.version && (
|
|
116
|
+
<span className="font-mono text-[10px] text-muted-foreground/70 shrink-0">
|
|
117
|
+
v{info.version}
|
|
118
|
+
</span>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Controls */}
|
|
123
|
+
<div className="shrink-0 border-b px-3 py-3 space-y-2">
|
|
124
|
+
{hasMultipleSchemas && (
|
|
125
|
+
<Combobox
|
|
126
|
+
options={schemaOptions}
|
|
127
|
+
value={currentSchemaId ?? ''}
|
|
128
|
+
onValueChange={(id) => id && onSchemaChange(id)}
|
|
129
|
+
placeholder="Select API"
|
|
130
|
+
searchPlaceholder="Search APIs…"
|
|
131
|
+
emptyText="No APIs found"
|
|
132
|
+
className="w-full h-8 text-xs"
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
<div className="relative">
|
|
136
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50 pointer-events-none" />
|
|
137
|
+
<Input
|
|
138
|
+
placeholder="Search endpoints…"
|
|
139
|
+
value={search}
|
|
140
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
|
141
|
+
className="pl-8 h-8 text-xs"
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<ScrollArea>
|
|
147
|
+
{filteredGroups.length === 0 ? (
|
|
148
|
+
<div className="py-10 px-4 text-center text-xs text-muted-foreground">
|
|
149
|
+
{debounced
|
|
150
|
+
? `No endpoints match "${debounced}"`
|
|
151
|
+
: 'No endpoints in this schema'}
|
|
152
|
+
</div>
|
|
153
|
+
) : (
|
|
154
|
+
<nav className="py-2">
|
|
155
|
+
{filteredGroups.map((group) => (
|
|
156
|
+
<div key={group.category} className="mb-4 last:mb-2">
|
|
157
|
+
<div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/50 select-none">
|
|
158
|
+
{group.category}
|
|
159
|
+
</div>
|
|
160
|
+
<div>
|
|
161
|
+
{group.endpoints.map((ep) => {
|
|
162
|
+
const anchor = endpointAnchor(ep);
|
|
163
|
+
const isActive = activeEndpointId === anchor;
|
|
164
|
+
const label = sidebarLabel(ep, group.commonPrefix);
|
|
165
|
+
const tooltip = sidebarTooltip(ep);
|
|
166
|
+
// Summary → sans-serif (reads like an outline).
|
|
167
|
+
// Path-tail fallback → mono (reads like code).
|
|
168
|
+
const useMono = !ep.summary;
|
|
169
|
+
return (
|
|
170
|
+
<Tooltip key={`${ep.method}-${ep.path}`} delayDuration={350}>
|
|
171
|
+
<TooltipTrigger asChild>
|
|
172
|
+
<button
|
|
173
|
+
onClick={() => onNavigate(anchor)}
|
|
174
|
+
aria-current={isActive ? 'location' : undefined}
|
|
175
|
+
className={cn(
|
|
176
|
+
'relative group w-full text-left flex items-center gap-2 pl-4 pr-3 py-1.5 transition-colors',
|
|
177
|
+
isActive
|
|
178
|
+
? 'bg-primary/10 text-foreground'
|
|
179
|
+
: 'hover:bg-muted/40 text-foreground/75 hover:text-foreground',
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
{isActive && (
|
|
183
|
+
<span className="absolute left-0 top-1 bottom-1 w-0.5 rounded-r bg-primary" />
|
|
184
|
+
)}
|
|
185
|
+
<MethodBadge method={ep.method} />
|
|
186
|
+
<span
|
|
187
|
+
className={cn(
|
|
188
|
+
'truncate leading-tight flex-1 min-w-0',
|
|
189
|
+
useMono ? 'font-mono text-[11px]' : 'text-[12px]',
|
|
190
|
+
isActive && 'text-foreground font-medium',
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
{label}
|
|
194
|
+
</span>
|
|
195
|
+
</button>
|
|
196
|
+
</TooltipTrigger>
|
|
197
|
+
<TooltipContent side="right" align="center" className="font-mono text-[11px]">
|
|
198
|
+
{tooltip}
|
|
199
|
+
</TooltipContent>
|
|
200
|
+
</Tooltip>
|
|
201
|
+
);
|
|
202
|
+
})}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
))}
|
|
206
|
+
</nav>
|
|
207
|
+
)}
|
|
208
|
+
</ScrollArea>
|
|
209
|
+
</aside>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -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
|
+
};
|