@djangocfg/ui-tools 2.1.287 → 2.1.290

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +14 -3
  2. package/dist/DocsLayout-IKH7BLSU.cjs +3464 -0
  3. package/dist/DocsLayout-IKH7BLSU.cjs.map +1 -0
  4. package/dist/DocsLayout-JPXFUKAR.mjs +3457 -0
  5. package/dist/DocsLayout-JPXFUKAR.mjs.map +1 -0
  6. package/dist/{PrettyCode.client-5GABIN2I.cjs → PrettyCode.client-RPDIE5CH.cjs} +104 -3
  7. package/dist/PrettyCode.client-RPDIE5CH.cjs.map +1 -0
  8. package/dist/{PrettyCode.client-IZTXXYHG.mjs → PrettyCode.client-SPMTQEG4.mjs} +106 -5
  9. package/dist/PrettyCode.client-SPMTQEG4.mjs.map +1 -0
  10. package/dist/{chunk-IULI4XII.cjs → chunk-5Q4UMSWB.cjs} +355 -9
  11. package/dist/chunk-5Q4UMSWB.cjs.map +1 -0
  12. package/dist/{chunk-VZGQC3NG.mjs → chunk-EFWOJPA6.mjs} +349 -9
  13. package/dist/chunk-EFWOJPA6.mjs.map +1 -0
  14. package/dist/index.cjs +10 -10
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +34 -0
  17. package/dist/index.d.ts +34 -0
  18. package/dist/index.mjs +5 -5
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +21 -14
  21. package/src/components/markdown/MarkdownMessage.tsx +46 -0
  22. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +93 -157
  23. package/src/tools/OpenapiViewer/README.md +114 -6
  24. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +20 -6
  25. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +331 -53
  26. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/LanguageTabs.tsx +36 -0
  27. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/index.tsx +56 -0
  28. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/useCodeSnippet.ts +77 -0
  29. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +146 -0
  30. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MethodBadge.tsx +6 -0
  31. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/PathDisplay.tsx +26 -0
  32. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/index.tsx +87 -0
  33. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamGroup.tsx +30 -0
  34. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamRow.tsx +36 -0
  35. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/index.tsx +22 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/RequestBody/index.tsx +33 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseBody.tsx +76 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseRow.tsx +80 -0
  39. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +32 -0
  40. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/index.tsx +21 -0
  41. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/FieldRow.tsx +106 -0
  42. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/buildTree.ts +127 -0
  43. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/index.tsx +31 -0
  44. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/types.ts +28 -0
  45. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +87 -0
  46. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/defaults.ts +27 -0
  47. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/index.tsx +45 -0
  48. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/context.tsx +56 -0
  49. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/hooks/useSectionHash.ts +63 -0
  50. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/index.tsx +96 -0
  51. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/index.ts +133 -0
  52. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/selectors.ts +40 -0
  53. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/types.ts +17 -0
  54. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +40 -11
  55. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/BrandHeader.tsx +48 -0
  56. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/CategoryBlock.tsx +33 -0
  57. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/EndpointRow.tsx +73 -0
  58. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/MethodChips.tsx +43 -0
  59. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SchemaSection.tsx +27 -0
  60. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SearchInput.tsx +45 -0
  61. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SidebarBody.tsx +50 -0
  62. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/Toolbar.tsx +64 -0
  63. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/buildVM.ts +126 -0
  64. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/index.tsx +112 -0
  65. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/types.ts +42 -0
  66. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/useDebouncedValue.ts +14 -0
  67. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +10 -7
  68. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +9 -6
  69. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +19 -2
  70. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +38 -21
  71. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +168 -50
  72. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PrettyView.tsx +55 -0
  73. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PreviewView.tsx +115 -0
  74. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/RawView.tsx +24 -0
  75. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/StatusBar.tsx +63 -0
  76. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/ViewTabs.tsx +45 -0
  77. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/detectContent.ts +97 -0
  78. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/index.tsx +93 -0
  79. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/types.ts +26 -0
  80. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/useResponseView.ts +62 -0
  81. package/src/tools/OpenapiViewer/hooks/index.ts +3 -1
  82. package/src/tools/OpenapiViewer/hooks/useDocsUrlSync.ts +119 -0
  83. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +164 -74
  84. package/src/tools/OpenapiViewer/types.ts +46 -1
  85. package/src/tools/OpenapiViewer/utils/codeSamples.ts +287 -0
  86. package/src/tools/OpenapiViewer/utils/index.ts +3 -0
  87. package/src/tools/OpenapiViewer/utils/operationToHar.ts +119 -0
  88. package/src/tools/OpenapiViewer/utils/sampler.ts +72 -0
  89. package/src/tools/OpenapiViewer/utils/scrollParent.ts +68 -0
  90. package/src/tools/PrettyCode/PrettyCode.client.tsx +88 -1
  91. package/src/tools/PrettyCode/PrettyCode.story.tsx +114 -361
  92. package/src/tools/PrettyCode/index.tsx +13 -0
  93. package/src/tools/PrettyCode/lazy.tsx +5 -0
  94. package/src/tools/PrettyCode/registerPrismLanguages.ts +111 -0
  95. package/dist/DocsLayout-BCVU6TTX.cjs +0 -2027
  96. package/dist/DocsLayout-BCVU6TTX.cjs.map +0 -1
  97. package/dist/DocsLayout-ERETJLLV.mjs +0 -2020
  98. package/dist/DocsLayout-ERETJLLV.mjs.map +0 -1
  99. package/dist/PrettyCode.client-5GABIN2I.cjs.map +0 -1
  100. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +0 -1
  101. package/dist/chunk-IULI4XII.cjs.map +0 -1
  102. package/dist/chunk-VZGQC3NG.mjs.map +0 -1
  103. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +0 -268
  104. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +0 -211
  105. package/src/tools/OpenapiViewer/components/shared/ResponsePanel.tsx +0 -127
@@ -1,14 +1,17 @@
1
1
  'use client';
2
2
 
3
- import React, { useCallback, useRef, useState } from 'react';
3
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
4
+ import { keyBy } from 'lodash-es';
4
5
 
5
6
  import { Skeleton, TooltipProvider } from '@djangocfg/ui-core/components';
6
7
  import { useMediaQuery } from '@djangocfg/ui-core/hooks';
7
8
 
8
9
  import useOpenApiSchema from '../../hooks/useOpenApiSchema';
10
+ import { useDocsUrlSync, type ParsedHash } from '../../hooks/useDocsUrlSync';
9
11
  import { usePlaygroundContext } from '../../context/PlaygroundContext';
10
12
  import type { ApiEndpoint } from '../../types';
11
13
  import { EndpointDraftSync } from '../shared/EndpointDraftSync';
14
+ import { slugifySchemaId } from './anchor';
12
15
  import { DocsSidebar } from './Sidebar';
13
16
  import { DocsView, type DocsViewHandle } from './DocsView';
14
17
  import { SlideInPlayground } from './SlideInPlayground';
@@ -24,6 +27,12 @@ export const DocsLayout: React.FC = () => {
24
27
  // mobile-style ``TryItSheet`` on those viewports.
25
28
  const isDesktop = useMediaQuery('(min-width: 1024px)');
26
29
  const isMobile = !isDesktop;
30
+
31
+ const grouping = config.schemaGrouping ?? 'selector';
32
+ const preloadAll = grouping === 'sections';
33
+ const urlSyncEnabled =
34
+ typeof config.urlSync === 'boolean' ? config.urlSync : Boolean(config.urlSync?.enabled);
35
+
27
36
  const {
28
37
  endpoints,
29
38
  schemaInfo,
@@ -34,13 +43,16 @@ export const DocsLayout: React.FC = () => {
34
43
  schemas,
35
44
  currentSchema,
36
45
  setCurrentSchema,
46
+ schemasData,
37
47
  } = useOpenApiSchema({
38
48
  schemas: config.schemas,
39
49
  defaultSchemaId: config.defaultSchemaId,
40
50
  baseUrl: config.baseUrl,
51
+ preloadAll,
41
52
  });
42
53
 
43
54
  const [activeAnchor, setActiveAnchor] = useState<string | null>(null);
55
+ const [activeSchemaId, setActiveSchemaId] = useState<string | null>(null);
44
56
  const [sheetOpen, setSheetOpen] = useState(false);
45
57
  const docsRef = useRef<DocsViewHandle | null>(null);
46
58
 
@@ -49,6 +61,17 @@ export const DocsLayout: React.FC = () => {
49
61
  // semantic — "which endpoint is loaded into the playground".
50
62
  const slideOpen = !isMobile && state.selectedEndpoint !== null;
51
63
 
64
+ // Per-schema endpoint map for the sections sidebar. ``keyBy`` makes the
65
+ // lookup O(1) at render time instead of scanning schemasData in each
66
+ // CategoryBlock — a win for 10+ schemas.
67
+ const endpointsBySchema = useMemo<Record<string, ApiEndpoint[]>>(() => {
68
+ if (grouping !== 'sections') return {};
69
+ const byId = keyBy(schemasData, (e) => e.source.id);
70
+ const out: Record<string, ApiEndpoint[]> = {};
71
+ for (const src of schemas) out[src.id] = byId[src.id]?.endpoints ?? [];
72
+ return out;
73
+ }, [grouping, schemasData, schemas]);
74
+
52
75
  const handleTry = useCallback(
53
76
  (ep: ApiEndpoint) => {
54
77
  setSelectedEndpoint(ep);
@@ -61,18 +84,83 @@ export const DocsLayout: React.FC = () => {
61
84
  setSelectedEndpoint(null);
62
85
  }, [setSelectedEndpoint]);
63
86
 
64
- const handleNavigate = useCallback((anchor: string) => {
65
- docsRef.current?.scrollToAnchor(anchor);
87
+ const handleNavigate = useCallback(
88
+ (anchor: string, schemaId?: string | null) => {
89
+ // In selector mode a schema switch may be required before the
90
+ // anchor exists in the DOM — defer the scroll until the next
91
+ // paint so ``useOpenApiSchema`` has a chance to swap endpoints.
92
+ if (schemaId && schemaId !== currentSchema?.id && grouping === 'selector') {
93
+ setCurrentSchema(schemaId);
94
+ requestAnimationFrame(() => {
95
+ docsRef.current?.scrollToAnchor(anchor);
96
+ });
97
+ return;
98
+ }
99
+ docsRef.current?.scrollToAnchor(anchor);
100
+ },
101
+ [currentSchema?.id, grouping, setCurrentSchema],
102
+ );
103
+
104
+ const handleActiveChange = useCallback((anchor: string | null, schemaId: string | null) => {
105
+ setActiveAnchor(anchor);
106
+ setActiveSchemaId(schemaId);
66
107
  }, []);
67
- // Esc handling lives inside SidePanel itself — no duplicate listener.
108
+
109
+ // URL sync: read hash on mount / popstate → apply; write hash when
110
+ // scrollspy updates. Only the *effective* active schema goes into the
111
+ // hash — in ``selector`` mode it's the combobox value, in ``sections``
112
+ // mode it's whichever schema the scrollspy is currently inside.
113
+ const effectiveSchemaId = grouping === 'sections' ? activeSchemaId : currentSchema?.id ?? null;
114
+
115
+ const handleHashTarget = useCallback(
116
+ (target: ParsedHash) => {
117
+ if (!target.schemaId && !target.anchor) return;
118
+
119
+ // Schema-id segment may be either the raw id or a slug — match
120
+ // both so copy-pasted URLs survive id changes that don't affect
121
+ // the slug. First match wins.
122
+ const matched = target.schemaId
123
+ ? schemas.find((s) => s.id === target.schemaId || slugifySchemaId(s.id) === target.schemaId)
124
+ : null;
125
+
126
+ const needsSchemaSwitch =
127
+ matched && grouping === 'selector' && matched.id !== currentSchema?.id;
128
+
129
+ if (needsSchemaSwitch) {
130
+ setCurrentSchema(matched.id);
131
+ }
132
+
133
+ if (target.anchor) {
134
+ const anchor = target.anchor;
135
+ // Wait one frame when a switch happened so the new DOM exists.
136
+ if (needsSchemaSwitch) {
137
+ requestAnimationFrame(() => {
138
+ docsRef.current?.scrollToAnchor(anchor);
139
+ });
140
+ } else {
141
+ docsRef.current?.scrollToAnchor(anchor);
142
+ }
143
+ }
144
+ },
145
+ [schemas, grouping, currentSchema?.id, setCurrentSchema],
146
+ );
147
+
148
+ useDocsUrlSync({
149
+ enabled: urlSyncEnabled,
150
+ currentSchemaId: effectiveSchemaId,
151
+ activeAnchor,
152
+ onHashTarget: handleHashTarget,
153
+ });
154
+
155
+ // ─── Loading / error branches ─────────────────────────────────────────
68
156
 
69
157
  if (loading) {
70
158
  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">
159
+ <div className="grid grid-cols-[260px_1fr] items-start">
160
+ <div
161
+ className="sticky top-[var(--navbar-height,64px)] border-r p-3 space-y-1.5 overflow-y-auto"
162
+ style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
163
+ >
76
164
  {Array.from({ length: 12 }).map((_, i) => (
77
165
  <Skeleton key={i} className="h-8 w-full rounded" />
78
166
  ))}
@@ -105,58 +193,88 @@ export const DocsLayout: React.FC = () => {
105
193
  );
106
194
  }
107
195
 
108
- // Mobile/tablet: sidebar + docs only, playground opens in sheet.
196
+ // ─── Mobile: sidebar + docs only, playground opens in sheet ───────────
197
+
109
198
  if (isMobile) {
110
199
  return (
111
- <div
112
- className="flex flex-col overflow-hidden"
113
- style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
114
- >
200
+ <div className="flex flex-col">
115
201
  <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
- />
202
+ {grouping === 'sections' ? (
203
+ <DocsView
204
+ ref={docsRef}
205
+ grouping="sections"
206
+ schemasData={schemasData}
207
+ selectedVersion={state.selectedVersion}
208
+ loadedEndpoint={state.selectedEndpoint}
209
+ onTryEndpoint={handleTry}
210
+ onActiveChange={handleActiveChange}
211
+ />
212
+ ) : (
213
+ <DocsView
214
+ ref={docsRef}
215
+ info={schemaInfo}
216
+ rawSchema={rawSchema}
217
+ resolvedBaseUrl={resolvedBaseUrl}
218
+ endpoints={endpoints}
219
+ selectedVersion={state.selectedVersion}
220
+ loadedEndpoint={state.selectedEndpoint}
221
+ onTryEndpoint={handleTry}
222
+ onActiveChange={handleActiveChange}
223
+ />
224
+ )}
127
225
  <TryItSheet open={sheetOpen} onOpenChange={setSheetOpen} />
128
226
  </div>
129
227
  );
130
228
  }
131
229
 
230
+ // ─── Desktop ──────────────────────────────────────────────────────────
231
+
132
232
  return (
133
233
  <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
- >
234
+ <div className="grid grid-cols-[260px_minmax(0,1fr)] items-start">
138
235
  <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
- />
236
+ <div
237
+ className="sticky top-[var(--navbar-height,64px)]"
238
+ style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
239
+ >
240
+ <DocsSidebar
241
+ info={schemaInfo}
242
+ endpoints={endpoints}
243
+ schemas={schemas}
244
+ currentSchemaId={currentSchema?.id ?? null}
245
+ onSchemaChange={setCurrentSchema}
246
+ activeEndpointId={activeAnchor}
247
+ selectedVersion={state.selectedVersion}
248
+ onNavigate={handleNavigate}
249
+ grouping={grouping}
250
+ endpointsBySchema={endpointsBySchema}
251
+ rawSchema={rawSchema}
252
+ resolvedBaseUrl={resolvedBaseUrl}
253
+ />
254
+ </div>
255
+ {grouping === 'sections' ? (
256
+ <DocsView
257
+ ref={docsRef}
258
+ grouping="sections"
259
+ schemasData={schemasData}
260
+ selectedVersion={state.selectedVersion}
261
+ loadedEndpoint={state.selectedEndpoint}
262
+ onTryEndpoint={handleTry}
263
+ onActiveChange={handleActiveChange}
264
+ />
265
+ ) : (
266
+ <DocsView
267
+ ref={docsRef}
268
+ info={schemaInfo}
269
+ rawSchema={rawSchema}
270
+ resolvedBaseUrl={resolvedBaseUrl}
271
+ endpoints={endpoints}
272
+ selectedVersion={state.selectedVersion}
273
+ loadedEndpoint={state.selectedEndpoint}
274
+ onTryEndpoint={handleTry}
275
+ onActiveChange={handleActiveChange}
276
+ />
277
+ )}
160
278
  {/* SidePanel renders into <body> via portal, so it floats
161
279
  above the whole layout (sidebar + navbar included). */}
162
280
  <SlideInPlayground open={slideOpen} onClose={handleCloseSlide} />
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import JsonTree from '../../../../JsonTree';
4
+ import PrettyCode from '../../../../PrettyCode';
5
+ import type { DetectedContent } from './types';
6
+
7
+ // JsonTree config — same shape as the interactive docs example view
8
+ // but with ``showActionButtons: false`` because the copy button in the
9
+ // StatusBar already covers that flow.
10
+ const JSON_TREE_CONFIG = {
11
+ maxAutoExpandDepth: 2,
12
+ maxAutoExpandArrayItems: 10,
13
+ maxAutoExpandObjectKeys: 5,
14
+ maxStringLength: 200,
15
+ collectionLimit: 50,
16
+ showCollectionInfo: true,
17
+ showExpandControls: true,
18
+ showActionButtons: false,
19
+ preserveKeyOrder: true,
20
+ className: 'border-0 rounded-none',
21
+ } as const;
22
+
23
+ interface PrettyViewProps {
24
+ /** Parsed JSON tree when the body was JSON. */
25
+ treeData: unknown;
26
+ /** Pre-stringified body — used by the code-highlight branch for
27
+ * non-JSON payloads. */
28
+ rawText: string;
29
+ detected: DetectedContent;
30
+ }
31
+
32
+ /** "Pretty" view — rich-rendering branch. JSON renders as ``JsonTree``;
33
+ * anything else goes through ``PrettyCode`` in ``plain`` variant so it
34
+ * flows naturally inside the panel's ``ScrollArea`` — no internal
35
+ * scroll, no floating toolbar fighting the surrounding layout. */
36
+ export function PrettyView({ treeData, rawText, detected }: PrettyViewProps) {
37
+ if (detected.kind === 'json' && treeData != null) {
38
+ return <JsonTree title="Response Body" data={treeData} config={JSON_TREE_CONFIG} />;
39
+ }
40
+ if (!rawText) {
41
+ return (
42
+ <div className="py-10 text-center text-xs text-muted-foreground">
43
+ Empty response body
44
+ </div>
45
+ );
46
+ }
47
+ return (
48
+ <PrettyCode
49
+ data={rawText}
50
+ language={detected.prism}
51
+ variant="plain"
52
+ isCompact
53
+ />
54
+ );
55
+ }
@@ -0,0 +1,115 @@
1
+ 'use client';
2
+
3
+ import { Info, ShieldCheck } from 'lucide-react';
4
+ import { useMemo } from 'react';
5
+
6
+ interface PreviewViewProps {
7
+ html: string;
8
+ }
9
+
10
+ /** Heuristic: does ``html`` look like a JS-only single-page app shell?
11
+ *
12
+ * SPAs (Vite, CRA, Next-app dev index) ship a near-empty ``<body>``
13
+ * with a mount div + a ``<script type="module">`` that hydrates the
14
+ * page at runtime. Previewing those in a ``sandbox=""`` iframe is
15
+ * pointless because scripts can't run — the reader sees a blank
16
+ * page and assumes preview is broken.
17
+ *
18
+ * We detect this by extracting body content, stripping scripts, and
19
+ * checking what's left. If it's basically just a mount div, we show
20
+ * an explanatory empty-state instead of an empty iframe. */
21
+ function looksLikeSpaShell(html: string): boolean {
22
+ const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
23
+ const bodyContent = (bodyMatch?.[1] ?? html)
24
+ // Strip all script tags with their contents — runtime-only code
25
+ // doesn't count as visible markup for this check.
26
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
27
+ // Strip comments.
28
+ .replace(/<!--[\s\S]*?-->/g, '')
29
+ .trim();
30
+
31
+ if (bodyContent.length === 0) return true;
32
+
33
+ // A single empty container (``<div id="root"></div>`` and friends)
34
+ // is the classic SPA mount point. Anything else — including static
35
+ // server-rendered pages with real content — won't match.
36
+ const singleEmptyContainer = /^<(div|main|section)[^>]*>\s*<\/\1>$/i;
37
+ if (singleEmptyContainer.test(bodyContent)) return true;
38
+
39
+ return false;
40
+ }
41
+
42
+ /** Render an HTML response inside a sandboxed iframe so the reader can
43
+ * see what the server's error page or template actually looks like.
44
+ *
45
+ * Security model — ``sandbox`` is intentionally **empty**:
46
+ * - no ``allow-scripts`` → JavaScript in the response cannot run
47
+ * - no ``allow-same-origin`` → the page can't read parent cookies
48
+ * - no ``allow-forms`` / ``allow-popups`` → can't phish the user
49
+ *
50
+ * We feed the HTML via ``srcDoc`` (not ``src``) so we never issue a
51
+ * network request to render it — the string is already local. */
52
+ export function PreviewView({ html }: PreviewViewProps) {
53
+ const isSpaShell = useMemo(() => looksLikeSpaShell(html), [html]);
54
+
55
+ if (!html) {
56
+ return (
57
+ <div className="py-10 text-center text-xs text-muted-foreground">
58
+ Empty response body
59
+ </div>
60
+ );
61
+ }
62
+
63
+ if (isSpaShell) {
64
+ return (
65
+ <div className="flex flex-col items-center justify-center py-16 px-6 text-center gap-3 min-h-[400px]">
66
+ <div className="inline-flex items-center justify-center h-10 w-10 rounded-full bg-muted">
67
+ <Info className="h-5 w-5 text-muted-foreground" />
68
+ </div>
69
+ <div className="max-w-sm space-y-1.5">
70
+ <p className="text-sm font-medium text-foreground">
71
+ Looks like a single-page app shell
72
+ </p>
73
+ <p className="text-xs text-muted-foreground leading-relaxed">
74
+ This page renders its content with JavaScript at runtime.
75
+ Scripts are disabled in the sandbox, so Preview would show
76
+ a blank page. Switch to <strong>Pretty</strong> or{' '}
77
+ <strong>Raw</strong> to inspect the HTML source.
78
+ </p>
79
+ </div>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ return (
85
+ <div className="flex flex-col h-full min-h-[400px]">
86
+ <div className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 bg-muted/30 border-b text-[10px] text-muted-foreground/70">
87
+ <ShieldCheck className="h-3 w-3" />
88
+ Sandboxed preview — scripts, forms and popups are disabled
89
+ </div>
90
+ {/*
91
+ * Checker-pattern background so "black on black" responses
92
+ * still show a clear iframe bounding box, and so readers
93
+ * can tell the iframe has loaded even if the page itself
94
+ * is empty / fully transparent.
95
+ */}
96
+ <div
97
+ className="flex-1 min-h-[360px] p-2"
98
+ style={{
99
+ backgroundColor: '#fff',
100
+ backgroundImage:
101
+ 'linear-gradient(45deg, #f3f4f6 25%, transparent 25%), linear-gradient(-45deg, #f3f4f6 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #f3f4f6 75%), linear-gradient(-45deg, transparent 75%, #f3f4f6 75%)',
102
+ backgroundSize: '16px 16px',
103
+ backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
104
+ }}
105
+ >
106
+ <iframe
107
+ title="Response preview"
108
+ srcDoc={html}
109
+ sandbox=""
110
+ className="w-full h-full min-h-[360px] bg-white border-0 rounded shadow-sm"
111
+ />
112
+ </div>
113
+ </div>
114
+ );
115
+ }
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+
3
+ interface RawViewProps {
4
+ rawText: string;
5
+ }
6
+
7
+ /** Verbatim response body. No parsing, no highlighting — readers
8
+ * occasionally need to inspect trailing whitespace, escape
9
+ * sequences, or a payload that accidentally claims one content type
10
+ * and ships another, and the Pretty view hides some of that. */
11
+ export function RawView({ rawText }: RawViewProps) {
12
+ if (!rawText) {
13
+ return (
14
+ <div className="py-10 text-center text-xs text-muted-foreground">
15
+ Empty response body
16
+ </div>
17
+ );
18
+ }
19
+ return (
20
+ <pre className="p-4 text-[11px] font-mono text-foreground/70 whitespace-pre-wrap break-all leading-relaxed">
21
+ {rawText}
22
+ </pre>
23
+ );
24
+ }
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ import { CopyButton } from '@djangocfg/ui-core/components';
4
+
5
+ import type { ApiResponse } from '../../../types';
6
+ import { StatusBadge } from '../ui';
7
+
8
+ interface StatusBarProps {
9
+ response: ApiResponse;
10
+ rawText: string;
11
+ /** Content-Type label shown inline next to size/duration. ``null``
12
+ * when the server didn't send one — we hide the slot instead of
13
+ * showing an empty dash. */
14
+ contentType: string | null;
15
+ }
16
+
17
+ /** Top strip of the response panel: status badge, statusText, size,
18
+ * duration, content-type, copy button. Compact — one line, tabular
19
+ * numerals so 1.4 KB and 107ms don't shift as values update. */
20
+ export function StatusBar({ response, rawText, contentType }: StatusBarProps) {
21
+ const sizeKb = rawText ? `${(rawText.length / 1024).toFixed(1)} KB` : '';
22
+ const duration = response.duration != null ? `${response.duration}ms` : '';
23
+ const hasStatus = response.status != null;
24
+ const hasCopy = Boolean(rawText);
25
+
26
+ return (
27
+ <div className="shrink-0 border-b px-4 py-2 flex items-center justify-between gap-3 bg-muted/20">
28
+ <div className="flex items-center gap-2 min-w-0">
29
+ {hasStatus && <StatusBadge status={response.status!} />}
30
+ {response.statusText && (
31
+ <span className="text-xs text-muted-foreground truncate">
32
+ {response.statusText}
33
+ </span>
34
+ )}
35
+ {sizeKb && (
36
+ <span className="text-[10px] text-muted-foreground/50 tabular-nums shrink-0">
37
+ {sizeKb}
38
+ </span>
39
+ )}
40
+ {duration && (
41
+ <span className="text-[10px] text-muted-foreground/50 tabular-nums shrink-0">
42
+ {duration}
43
+ </span>
44
+ )}
45
+ {contentType && (
46
+ <span className="text-[10px] text-muted-foreground/50 font-mono truncate">
47
+ {contentType}
48
+ </span>
49
+ )}
50
+ </div>
51
+ {hasCopy && (
52
+ <CopyButton
53
+ value={rawText}
54
+ variant="ghost"
55
+ size="sm"
56
+ className="h-6 px-2 text-[10px] text-muted-foreground shrink-0"
57
+ >
58
+ Copy
59
+ </CopyButton>
60
+ )}
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ import type { ViewMode } from './types';
6
+
7
+ interface ViewTabsProps {
8
+ active: ViewMode;
9
+ onChange: (mode: ViewMode) => void;
10
+ /** When false the Preview tab is hidden — only HTML responses get
11
+ * a useful preview, everything else renders the same as Pretty. */
12
+ showPreview: boolean;
13
+ }
14
+
15
+ const LABELS: Record<ViewMode, string> = {
16
+ pretty: 'Pretty',
17
+ raw: 'Raw',
18
+ preview: 'Preview',
19
+ };
20
+
21
+ /** Tab strip for switching between Pretty / Raw / Preview. Matches the
22
+ * visual weight of the ``LanguageTabs`` in CodeSamples so the page
23
+ * reads as one coherent toolbar system. */
24
+ export function ViewTabs({ active, onChange, showPreview }: ViewTabsProps) {
25
+ const tabs: ViewMode[] = showPreview ? ['pretty', 'raw', 'preview'] : ['pretty', 'raw'];
26
+ return (
27
+ <div className="shrink-0 border-b px-3 py-1.5 flex items-center gap-1">
28
+ {tabs.map((t) => (
29
+ <button
30
+ key={t}
31
+ type="button"
32
+ onClick={() => onChange(t)}
33
+ className={cn(
34
+ 'h-6 px-2.5 rounded text-[11px] font-medium transition-colors',
35
+ active === t
36
+ ? 'bg-muted text-foreground'
37
+ : 'text-muted-foreground/70 hover:text-foreground hover:bg-muted/50',
38
+ )}
39
+ >
40
+ {LABELS[t]}
41
+ </button>
42
+ ))}
43
+ </div>
44
+ );
45
+ }