@djangocfg/ui-tools 2.1.289 → 2.1.291

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 (98) hide show
  1. package/README.md +14 -3
  2. package/dist/{DocsLayout-YDR7DSMM.cjs → DocsLayout-IKH7BLSU.cjs} +1537 -682
  3. package/dist/DocsLayout-IKH7BLSU.cjs.map +1 -0
  4. package/dist/{DocsLayout-TKJQ5W5E.mjs → DocsLayout-JPXFUKAR.mjs} +1429 -574
  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 +18 -10
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +35 -1
  17. package/dist/index.d.ts +35 -1
  18. package/dist/index.mjs +13 -5
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +20 -15
  21. package/src/components/markdown/MarkdownMessage.tsx +46 -0
  22. package/src/tools/MarkdownEditor/MarkdownEditor.tsx +42 -1
  23. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +87 -178
  24. package/src/tools/OpenapiViewer/README.md +114 -6
  25. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +20 -6
  26. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +6 -0
  27. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/LanguageTabs.tsx +36 -0
  28. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/index.tsx +56 -0
  29. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/useCodeSnippet.ts +77 -0
  30. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +146 -0
  31. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MethodBadge.tsx +6 -0
  32. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/PathDisplay.tsx +26 -0
  33. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/index.tsx +87 -0
  34. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamGroup.tsx +30 -0
  35. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamRow.tsx +36 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/index.tsx +22 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/RequestBody/index.tsx +33 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseBody.tsx +76 -0
  39. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseRow.tsx +80 -0
  40. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +32 -0
  41. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/index.tsx +21 -0
  42. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/FieldRow.tsx +106 -0
  43. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/buildTree.ts +127 -0
  44. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/index.tsx +31 -0
  45. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/types.ts +28 -0
  46. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +87 -0
  47. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/defaults.ts +27 -0
  48. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/index.tsx +45 -0
  49. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/context.tsx +56 -0
  50. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/hooks/useSectionHash.ts +63 -0
  51. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/index.tsx +96 -0
  52. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/index.ts +133 -0
  53. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/selectors.ts +40 -0
  54. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/types.ts +17 -0
  55. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +8 -2
  56. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/BrandHeader.tsx +48 -0
  57. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/CategoryBlock.tsx +33 -0
  58. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/EndpointRow.tsx +73 -0
  59. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/MethodChips.tsx +43 -0
  60. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SchemaSection.tsx +27 -0
  61. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SearchInput.tsx +45 -0
  62. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SidebarBody.tsx +50 -0
  63. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/Toolbar.tsx +64 -0
  64. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/buildVM.ts +126 -0
  65. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/index.tsx +112 -0
  66. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/types.ts +42 -0
  67. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/useDebouncedValue.ts +14 -0
  68. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +10 -7
  69. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +9 -6
  70. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PrettyView.tsx +55 -0
  71. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PreviewView.tsx +115 -0
  72. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/RawView.tsx +24 -0
  73. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/StatusBar.tsx +63 -0
  74. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/ViewTabs.tsx +45 -0
  75. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/detectContent.ts +97 -0
  76. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/index.tsx +93 -0
  77. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/types.ts +26 -0
  78. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/useResponseView.ts +62 -0
  79. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +41 -71
  80. package/src/tools/OpenapiViewer/types.ts +10 -0
  81. package/src/tools/OpenapiViewer/utils/codeSamples.ts +287 -0
  82. package/src/tools/OpenapiViewer/utils/index.ts +3 -0
  83. package/src/tools/OpenapiViewer/utils/operationToHar.ts +119 -0
  84. package/src/tools/OpenapiViewer/utils/sampler.ts +72 -0
  85. package/src/tools/PrettyCode/PrettyCode.client.tsx +88 -1
  86. package/src/tools/PrettyCode/PrettyCode.story.tsx +114 -361
  87. package/src/tools/PrettyCode/index.tsx +13 -0
  88. package/src/tools/PrettyCode/lazy.tsx +5 -0
  89. package/src/tools/PrettyCode/registerPrismLanguages.ts +111 -0
  90. package/dist/DocsLayout-TKJQ5W5E.mjs.map +0 -1
  91. package/dist/DocsLayout-YDR7DSMM.cjs.map +0 -1
  92. package/dist/PrettyCode.client-5GABIN2I.cjs.map +0 -1
  93. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +0 -1
  94. package/dist/chunk-IULI4XII.cjs.map +0 -1
  95. package/dist/chunk-VZGQC3NG.mjs.map +0 -1
  96. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +0 -273
  97. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +0 -439
  98. package/src/tools/OpenapiViewer/components/shared/ResponsePanel.tsx +0 -127
@@ -36,9 +36,17 @@ export function TryItSheet({ open, onOpenChange }: TryItSheetProps) {
36
36
  <ResponsiveSheetHeader className="px-4 py-3 border-b shrink-0">
37
37
  <ResponsiveSheetTitle className="text-sm">Playground</ResponsiveSheetTitle>
38
38
  </ResponsiveSheetHeader>
39
+ {/* Send button sits inside the Request section so it
40
+ reads as an action on the form, not an ambiguous
41
+ page-level footer when Response is also visible. */}
39
42
  <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'}>
43
+ <div className="flex-1 min-h-0 flex flex-col">
41
44
  <RequestPanel />
45
+ {state.selectedEndpoint && (
46
+ <div className="shrink-0 border-t px-4 py-3 bg-background">
47
+ <SendButton />
48
+ </div>
49
+ )}
42
50
  </div>
43
51
  {showResponse && (
44
52
  <div className="flex-1 min-h-0 flex flex-col">
@@ -46,11 +54,6 @@ export function TryItSheet({ open, onOpenChange }: TryItSheetProps) {
46
54
  </div>
47
55
  )}
48
56
  </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
57
  </ResponsiveSheetContent>
55
58
  </ResponsiveSheet>
56
59
  );
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ import type { ContentKind, DetectedContent } from './types';
2
+
3
+ /** Strip Content-Type parameters (``; charset=utf-8`` etc.) and lower-case. */
4
+ function normaliseContentType(raw: string | null): string | null {
5
+ if (!raw) return null;
6
+ const semi = raw.indexOf(';');
7
+ return (semi === -1 ? raw : raw.slice(0, semi)).trim().toLowerCase();
8
+ }
9
+
10
+ /** Pull Content-Type out of the headers bag. We accept both plain
11
+ * objects (``{ "content-type": "..." }``) and ``Headers``-like shapes
12
+ * with a ``.get`` method. The context type is ``any`` so we can't be
13
+ * stricter without plumbing a proper type through the response code
14
+ * path. */
15
+ function readContentType(headers: unknown): string | null {
16
+ if (!headers) return null;
17
+ // Headers instance — use ``.get`` (case-insensitive).
18
+ if (typeof (headers as Headers).get === 'function') {
19
+ return (headers as Headers).get('content-type');
20
+ }
21
+ // Plain object — look up with case-insensitive key match so both
22
+ // ``Content-Type`` and ``content-type`` work.
23
+ if (typeof headers === 'object') {
24
+ for (const [k, v] of Object.entries(headers as Record<string, unknown>)) {
25
+ if (k.toLowerCase() === 'content-type') {
26
+ return typeof v === 'string' ? v : null;
27
+ }
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** Map a normalised MIME type to our short ``ContentKind``. Unknown
34
+ * types fall back to ``text`` so PrettyCode still shows something
35
+ * sensible (plain text is a valid Prism language). */
36
+ function kindFromContentType(mime: string | null): ContentKind {
37
+ if (!mime) return 'text';
38
+ if (mime === 'application/json' || mime.endsWith('+json')) return 'json';
39
+ if (mime === 'text/html' || mime === 'application/xhtml+xml') return 'html';
40
+ if (
41
+ mime === 'application/xml' ||
42
+ mime === 'text/xml' ||
43
+ mime.endsWith('+xml')
44
+ ) return 'xml';
45
+ if (mime === 'text/css') return 'css';
46
+ if (
47
+ mime === 'application/javascript' ||
48
+ mime === 'text/javascript' ||
49
+ mime === 'application/x-javascript'
50
+ ) return 'javascript';
51
+ return 'text';
52
+ }
53
+
54
+ /** Heuristic fallback when ``Content-Type`` is missing or opaque.
55
+ * Returns ``null`` when nothing firm can be inferred; the caller
56
+ * then defaults to ``text``. */
57
+ function kindFromBody(body: string): ContentKind | null {
58
+ const trimmed = body.trimStart();
59
+ if (!trimmed) return null;
60
+ if (trimmed.startsWith('<!DOCTYPE') || /^<html[\s>]/i.test(trimmed)) return 'html';
61
+ if (trimmed.startsWith('<?xml')) return 'xml';
62
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
63
+ // Cheap validity check — full parse happens in ResponsePanel.
64
+ try { JSON.parse(trimmed); return 'json'; } catch { /* fall through */ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ const PRISM_BY_KIND: Record<ContentKind, DetectedContent['prism']> = {
70
+ json: 'json',
71
+ // ``markup`` is Prism's HTML/XML grammar — there isn't a separate
72
+ // ``html`` language. XML piggy-backs on the same tokeniser.
73
+ html: 'markup',
74
+ xml: 'markup',
75
+ css: 'css',
76
+ javascript: 'javascript',
77
+ text: 'markup',
78
+ };
79
+
80
+ /** Detect content kind from headers, falling back to body sniffing
81
+ * when the header is missing. Pure function — safe to call inside
82
+ * ``useMemo`` deps on the raw body and headers. */
83
+ export function detectContent(headers: unknown, rawBody: string): DetectedContent {
84
+ const contentType = normaliseContentType(readContentType(headers));
85
+ const headerKind = kindFromContentType(contentType);
86
+
87
+ // If headers say it's text/plain but the body looks like HTML or
88
+ // JSON, trust the body — a lot of framework 500 pages claim
89
+ // text/html but some setups default to text/plain for errors.
90
+ const kind = headerKind === 'text' ? (kindFromBody(rawBody) ?? 'text') : headerKind;
91
+
92
+ return {
93
+ kind,
94
+ prism: PRISM_BY_KIND[kind],
95
+ contentType,
96
+ };
97
+ }
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import { Loader2, Send, Terminal, WifiOff } from 'lucide-react';
4
+ import { useEffect, useState } from 'react';
5
+
6
+ import { usePlaygroundContext } from '../../../context/PlaygroundContext';
7
+ import { EmptyState, ScrollArea } from '../ui';
8
+ import { PreviewView } from './PreviewView';
9
+ import { PrettyView } from './PrettyView';
10
+ import { RawView } from './RawView';
11
+ import { StatusBar } from './StatusBar';
12
+ import type { ViewMode } from './types';
13
+ import { useResponseView } from './useResponseView';
14
+ import { ViewTabs } from './ViewTabs';
15
+
16
+ /** Response panel used by both the sticky ``SlideInPlayground`` and the
17
+ * modal ``TryItSheet``. Responsibilities:
18
+ * - Empty / loading / network-error gates
19
+ * - Three view modes (Pretty / Raw / Preview) with a tab bar
20
+ * - Auto-pick the best default view per response type (JSON → Pretty,
21
+ * HTML → Preview, everything else → Pretty as the widest-purpose)
22
+ *
23
+ * View mode is local component state rather than context state — it
24
+ * should reset when the selected endpoint changes and not leak between
25
+ * endpoints. */
26
+ export function ResponsePanel() {
27
+ const { state } = usePlaygroundContext();
28
+ const { response, loading, selectedEndpoint } = state;
29
+
30
+ const { treeData, rawText, detected } = useResponseView(response?.data, response?.headers);
31
+ const showPreview = detected.kind === 'html';
32
+
33
+ // Default view heuristic: show HTML pages preview-first so the
34
+ // reader sees the actual rendered server page immediately.
35
+ // Everything else lands on Pretty.
36
+ const [mode, setMode] = useState<ViewMode>(showPreview ? 'preview' : 'pretty');
37
+
38
+ // Reset the mode when the endpoint or response changes so a
39
+ // previously-selected tab from a different shape doesn't stick
40
+ // around (e.g. stuck on Preview when switching from HTML→JSON).
41
+ useEffect(() => {
42
+ setMode(showPreview ? 'preview' : 'pretty');
43
+ }, [selectedEndpoint, response, showPreview]);
44
+
45
+ if (loading) {
46
+ return (
47
+ <div className="flex items-center justify-center h-full gap-2">
48
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
49
+ <span className="text-xs text-muted-foreground">Sending…</span>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ if (!selectedEndpoint) return <EmptyState icon={Terminal} text="Response will appear here" />;
55
+ if (!response) return <EmptyState icon={Send} text='Press "Send Request" to see the response' />;
56
+
57
+ const hasError = Boolean(response.error);
58
+ const hasStatus = response.status != null;
59
+
60
+ // Pure network error (no HTTP response at all — CORS, offline, timeout)
61
+ if (hasError && !hasStatus) {
62
+ return (
63
+ <EmptyState
64
+ icon={WifiOff}
65
+ text={response.error!}
66
+ className="text-destructive [&_svg]:text-destructive"
67
+ />
68
+ );
69
+ }
70
+
71
+ return (
72
+ <>
73
+ <StatusBar response={response} rawText={rawText} contentType={detected.contentType} />
74
+
75
+ {/* HTTP-level error body (4xx/5xx — has status but also error flag) */}
76
+ {hasError && (
77
+ <div className="shrink-0 mx-4 mt-3 rounded border border-destructive/20 bg-destructive/5 px-3 py-2">
78
+ <p className="text-xs text-destructive">{response.error}</p>
79
+ </div>
80
+ )}
81
+
82
+ <ViewTabs active={mode} onChange={setMode} showPreview={showPreview} />
83
+
84
+ <ScrollArea>
85
+ {mode === 'pretty' && (
86
+ <PrettyView treeData={treeData} rawText={rawText} detected={detected} />
87
+ )}
88
+ {mode === 'raw' && <RawView rawText={rawText} />}
89
+ {mode === 'preview' && <PreviewView html={rawText} />}
90
+ </ScrollArea>
91
+ </>
92
+ );
93
+ }
@@ -0,0 +1,26 @@
1
+ import type { Language } from '../../../../PrettyCode';
2
+
3
+ /** Panels the user can switch between in the response view.
4
+ *
5
+ * - ``pretty``: structured rendering — JsonTree for JSON, syntax-
6
+ * highlighted code for HTML/XML/CSS/text.
7
+ * - ``raw``: plain ``<pre>`` dump of whatever the server returned.
8
+ * - ``preview``: sandboxed iframe for HTML responses (when you want
9
+ * to *see* an error page the server produced rather
10
+ * than read its source). Tab is hidden for non-HTML. */
11
+ export type ViewMode = 'pretty' | 'raw' | 'preview';
12
+
13
+ /** Content categories derived from the server's ``Content-Type`` header.
14
+ * We project the rich MIME type space onto a short enum so the renderer
15
+ * and tab-list only have to switch on a handful of values. */
16
+ export type ContentKind = 'json' | 'html' | 'xml' | 'css' | 'javascript' | 'text';
17
+
18
+ export interface DetectedContent {
19
+ kind: ContentKind;
20
+ /** Prism ``Language`` id matching ``kind`` — fed into PrettyCode. */
21
+ prism: Language;
22
+ /** Full Content-Type header value, stripped of parameters (e.g.
23
+ * ``application/json`` without ``; charset=utf-8``). ``null`` when
24
+ * the server didn't send one. */
25
+ contentType: string | null;
26
+ }
@@ -0,0 +1,62 @@
1
+ import { useMemo } from 'react';
2
+
3
+ import { detectContent } from './detectContent';
4
+ import type { DetectedContent } from './types';
5
+
6
+ interface UseResponseViewResult {
7
+ /** Parsed JSON value when the body is JSON, otherwise ``null``.
8
+ * JsonTree needs a live object to render properly, so we parse
9
+ * once up-front and cache. */
10
+ treeData: unknown;
11
+ /** Stringified body, always present when the server returned
12
+ * anything. Used by Raw and Pretty-for-text tabs. */
13
+ rawText: string;
14
+ /** Content-type + prism language inference. */
15
+ detected: DetectedContent;
16
+ }
17
+
18
+ /** Shape the raw response payload into the pieces each view tab
19
+ * needs. Exposed as a hook so the component stays thin and the
20
+ * normalisation is easy to test in isolation. */
21
+ export function useResponseView(
22
+ data: unknown,
23
+ headers: unknown,
24
+ ): UseResponseViewResult {
25
+ return useMemo(() => {
26
+ if (data == null) {
27
+ return {
28
+ treeData: null,
29
+ rawText: '',
30
+ detected: detectContent(headers, ''),
31
+ };
32
+ }
33
+
34
+ // String body — might be JSON-in-a-string or a plain text dump.
35
+ if (typeof data === 'string') {
36
+ try {
37
+ return {
38
+ treeData: JSON.parse(data),
39
+ rawText: data,
40
+ detected: detectContent(headers, data),
41
+ };
42
+ } catch {
43
+ return {
44
+ treeData: null,
45
+ rawText: data,
46
+ detected: detectContent(headers, data),
47
+ };
48
+ }
49
+ }
50
+
51
+ // Object body — axios already parsed it. Stringify for Raw/
52
+ // Preview tabs and treat it as JSON regardless of headers.
53
+ const stringified = (() => {
54
+ try { return JSON.stringify(data, null, 2); } catch { return String(data); }
55
+ })();
56
+ return {
57
+ treeData: data,
58
+ rawText: stringified,
59
+ detected: detectContent(headers, stringified),
60
+ };
61
+ }, [data, headers]);
62
+ }