@djangocfg/ui-tools 2.1.289 → 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 (97) 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 +10 -10
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +18 -0
  17. package/dist/index.d.ts +18 -0
  18. package/dist/index.mjs +5 -5
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +18 -14
  21. package/src/components/markdown/MarkdownMessage.tsx +46 -0
  22. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +87 -178
  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 +6 -0
  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 +8 -2
  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/shared/ResponsePanel/PrettyView.tsx +55 -0
  70. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PreviewView.tsx +115 -0
  71. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/RawView.tsx +24 -0
  72. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/StatusBar.tsx +63 -0
  73. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/ViewTabs.tsx +45 -0
  74. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/detectContent.ts +97 -0
  75. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/index.tsx +93 -0
  76. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/types.ts +26 -0
  77. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/useResponseView.ts +62 -0
  78. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +41 -71
  79. package/src/tools/OpenapiViewer/types.ts +10 -0
  80. package/src/tools/OpenapiViewer/utils/codeSamples.ts +287 -0
  81. package/src/tools/OpenapiViewer/utils/index.ts +3 -0
  82. package/src/tools/OpenapiViewer/utils/operationToHar.ts +119 -0
  83. package/src/tools/OpenapiViewer/utils/sampler.ts +72 -0
  84. package/src/tools/PrettyCode/PrettyCode.client.tsx +88 -1
  85. package/src/tools/PrettyCode/PrettyCode.story.tsx +114 -361
  86. package/src/tools/PrettyCode/index.tsx +13 -0
  87. package/src/tools/PrettyCode/lazy.tsx +5 -0
  88. package/src/tools/PrettyCode/registerPrismLanguages.ts +111 -0
  89. package/dist/DocsLayout-TKJQ5W5E.mjs.map +0 -1
  90. package/dist/DocsLayout-YDR7DSMM.cjs.map +0 -1
  91. package/dist/PrettyCode.client-5GABIN2I.cjs.map +0 -1
  92. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +0 -1
  93. package/dist/chunk-IULI4XII.cjs.map +0 -1
  94. package/dist/chunk-VZGQC3NG.mjs.map +0 -1
  95. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +0 -273
  96. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +0 -439
  97. package/src/tools/OpenapiViewer/components/shared/ResponsePanel.tsx +0 -127
@@ -13,7 +13,21 @@ interface ApiIntroSectionProps {
13
13
  resolvedBaseUrl?: string;
14
14
  }
15
15
 
16
+ interface BaseUrlRow {
17
+ url: string;
18
+ description?: string;
19
+ }
20
+
16
21
  export function ApiIntroSection({ info, schema, endpoints, resolvedBaseUrl }: ApiIntroSectionProps) {
22
+ // Prefer the *resolved* base URL whenever we have one — that's the
23
+ // URL actual requests target, not the raw ``servers[0].url`` from
24
+ // the spec (which can be a bare path like ``/api/v3``). Fall back
25
+ // to the spec's ``servers`` list so specs that document multiple
26
+ // servers keep showing all of them.
27
+ const baseUrlRows: BaseUrlRow[] = resolvedBaseUrl
28
+ ? [{ url: resolvedBaseUrl, description: info.servers?.[0]?.description }]
29
+ : (info.servers ?? []).map((s) => ({ url: s.url, description: s.description }));
30
+
17
31
  return (
18
32
  <section className="pb-10 mb-10 border-b">
19
33
  <div className="flex items-start justify-between gap-4 flex-wrap">
@@ -38,20 +52,20 @@ export function ApiIntroSection({ info, schema, endpoints, resolvedBaseUrl }: Ap
38
52
  </div>
39
53
  )}
40
54
 
41
- {info.servers && info.servers.length > 0 && (
55
+ {baseUrlRows.length > 0 && (
42
56
  <div className="mt-6 space-y-2">
43
57
  <h4 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
44
58
  Base URL
45
59
  </h4>
46
60
  <div className="space-y-1.5">
47
- {info.servers.map((s, i) => (
48
- <div key={`${s.url}-${i}`} className="flex items-baseline gap-2 flex-wrap">
61
+ {baseUrlRows.map((row, i) => (
62
+ <div key={`${row.url}-${i}`} className="flex items-baseline gap-2 flex-wrap">
49
63
  <code className="font-mono text-xs px-2 py-1 rounded bg-muted border">
50
- {s.url}
64
+ {row.url}
51
65
  </code>
52
- {s.description && (
66
+ {row.description && (
53
67
  <span className="text-xs text-muted-foreground">
54
- {s.description}
68
+ {row.description}
55
69
  </span>
56
70
  )}
57
71
  </div>
@@ -14,6 +14,7 @@ import {
14
14
  import { deduplicateEndpoints } from '../../utils/versionManager';
15
15
  import { ApiIntroSection } from './ApiIntroSection';
16
16
  import { EndpointDoc } from './EndpointDoc';
17
+ import { useSectionHashRouter } from './EndpointDoc/hooks/useSectionHash';
17
18
  import { SchemaCopyMenu } from './SchemaCopyMenu';
18
19
 
19
20
  export interface DocsViewHandle {
@@ -150,6 +151,11 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
150
151
  const scrollTargetRef = useRef<ScrollTarget | null>(null);
151
152
  const { onActiveChange } = props;
152
153
 
154
+ // ``#section=<endpointId>.<sectionId>`` shareable deep-links —
155
+ // opens the referenced section in the store and scrolls it in.
156
+ // Idempotent, attaches a single hashchange listener.
157
+ useSectionHashRouter();
158
+
153
159
  // Resolve the real scroll container once the ref is attached. In
154
160
  // standalone pages that's ``window``; inside an ``overflow-auto``
155
161
  // shell (dev playground, modal) it's the wrapping DIV.
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ import { CODE_SAMPLE_TARGETS, type CodeSampleTargetId } from '../../../../utils/codeSamples';
6
+
7
+ interface LanguageTabsProps {
8
+ activeId: CodeSampleTargetId;
9
+ onChange: (id: CodeSampleTargetId) => void;
10
+ }
11
+
12
+ /** Horizontal tab strip for switching between cURL/JS/Python/… The
13
+ * strip scrolls horizontally on narrow viewports rather than wrapping
14
+ * to a second line so the adjacent code block keeps its vertical
15
+ * rhythm. */
16
+ export function LanguageTabs({ activeId, onChange }: LanguageTabsProps) {
17
+ return (
18
+ <div className="flex items-center gap-1 overflow-x-auto -mx-1 px-1">
19
+ {CODE_SAMPLE_TARGETS.map((t) => (
20
+ <button
21
+ key={t.id}
22
+ type="button"
23
+ onClick={() => onChange(t.id)}
24
+ className={cn(
25
+ 'shrink-0 h-7 px-2.5 rounded text-xs font-medium transition-colors',
26
+ activeId === t.id
27
+ ? 'bg-muted text-foreground'
28
+ : 'text-muted-foreground/70 hover:text-foreground hover:bg-muted/50',
29
+ )}
30
+ >
31
+ {t.label}
32
+ </button>
33
+ ))}
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+
3
+ import PrettyCode from '../../../../../PrettyCode';
4
+ import type { ApiEndpoint } from '../../../../types';
5
+ import { useEndpointDocContext } from '../context';
6
+ import { useEndpointDocStore } from '../store';
7
+ import { useActiveCodeTab } from '../store/selectors';
8
+ import { LanguageTabs } from './LanguageTabs';
9
+ import { useCodeSnippet } from './useCodeSnippet';
10
+
11
+ interface CodeSamplesProps {
12
+ endpoint: ApiEndpoint;
13
+ /** Optional body to include in generated snippets. When omitted we
14
+ * use ``endpoint.requestBody?.example`` if present, so the snippet
15
+ * shows a realistic payload out of the box. */
16
+ body?: string;
17
+ /** Parameter values to substitute into the URL. Missing path params
18
+ * fall back to ``{name}`` placeholders so the snippet still
19
+ * illustrates the shape. */
20
+ parameters?: Record<string, string>;
21
+ /** Extra headers to include in snippets (e.g. a picked API key). */
22
+ headers?: Record<string, string>;
23
+ /** Base URL override — falls back to ``endpoint.path`` which
24
+ * already has the resolved base URL prepended by the extractor. */
25
+ baseUrl?: string;
26
+ }
27
+
28
+ /** Code samples block: language tab bar + highlighted snippet. The
29
+ * outer Section wrapper (collapsible) lives one level up; this
30
+ * component is always "open" from its own perspective. */
31
+ export function CodeSamples({ endpoint, body, parameters, headers, baseUrl }: CodeSamplesProps) {
32
+ const { endpointId } = useEndpointDocContext();
33
+ const activeId = useActiveCodeTab(endpointId);
34
+ const setCodeTab = useEndpointDocStore((s) => s.setCodeTab);
35
+
36
+ const { snippet, prism } = useCodeSnippet({
37
+ endpoint,
38
+ body,
39
+ parameters,
40
+ headers,
41
+ baseUrl,
42
+ activeId,
43
+ });
44
+
45
+ return (
46
+ <div className="space-y-2.5">
47
+ <LanguageTabs activeId={activeId} onChange={(id) => setCodeTab(endpointId, id)} />
48
+ <PrettyCode
49
+ data={snippet}
50
+ language={prism as never}
51
+ isCompact
52
+ maxLines={20}
53
+ />
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+
5
+ import type { ApiEndpoint } from '../../../../types';
6
+ import {
7
+ CODE_SAMPLE_TARGETS,
8
+ renderSnippet,
9
+ type CodeSampleTargetId,
10
+ } from '../../../../utils/codeSamples';
11
+ import { buildHarRequest } from '../../../../utils/operationToHar';
12
+ import { resolveAbsolute } from '../../../../utils/url';
13
+
14
+ interface UseCodeSnippetInput {
15
+ endpoint: ApiEndpoint;
16
+ body?: string;
17
+ parameters?: Record<string, string>;
18
+ headers?: Record<string, string>;
19
+ baseUrl?: string;
20
+ activeId: CodeSampleTargetId;
21
+ }
22
+
23
+ interface UseCodeSnippetResult {
24
+ /** Fully-rendered snippet string. Always present — falls back to an
25
+ * "unavailable" message if the generator returns null, so the
26
+ * consumer can always mount ``PrettyCode``. */
27
+ snippet: string;
28
+ /** Prism language id matching ``activeId`` — passed to PrettyCode so
29
+ * it picks the right highlighter. */
30
+ prism: string;
31
+ }
32
+
33
+ /** Encapsulates HAR build + snippet render + memoisation for the Code
34
+ * Samples block. Kept as a hook (rather than inline ``useMemo`` blocks
35
+ * in the component) so unit tests can exercise the snippet pipeline
36
+ * independently of React rendering. */
37
+ export function useCodeSnippet({
38
+ endpoint,
39
+ body,
40
+ parameters,
41
+ headers,
42
+ baseUrl,
43
+ activeId,
44
+ }: UseCodeSnippetInput): UseCodeSnippetResult {
45
+ const effectiveBody = body ?? endpoint.requestBody?.example;
46
+
47
+ // Build the HAR once per input change — every tab rebuilds its
48
+ // snippet from this shared request shape.
49
+ //
50
+ // ``endpoint.path`` already carries the schema's ``servers[0].url``
51
+ // (joined upstream in ``useOpenApiSchema``). Usually that's a path
52
+ // like ``/api/v3/pet`` — good enough for same-origin fetch, wrong
53
+ // for curl/python/go which need a runnable absolute URL. We hand
54
+ // off to ``resolveAbsolute`` so the snippet is copy-pasteable from
55
+ // a terminal without the user having to edit the host in manually.
56
+ //
57
+ // Priority: explicit ``baseUrl`` prop > resolved origin > bare path.
58
+ const har = useMemo(() => {
59
+ const h = buildHarRequest({
60
+ endpoint,
61
+ body: effectiveBody,
62
+ parameters,
63
+ headers,
64
+ baseUrl,
65
+ });
66
+ return baseUrl ? h : { ...h, url: resolveAbsolute(h.url) };
67
+ }, [endpoint, effectiveBody, parameters, headers, baseUrl]);
68
+
69
+ return useMemo(() => {
70
+ const target = CODE_SAMPLE_TARGETS.find((t) => t.id === activeId)!;
71
+ const code = renderSnippet(har, activeId);
72
+ return {
73
+ snippet: code ?? `// Snippet for ${target.label} is unavailable for this request.`,
74
+ prism: target.prism,
75
+ };
76
+ }, [har, activeId]);
77
+ }
@@ -0,0 +1,146 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Check,
5
+ ChevronsDownUp,
6
+ ChevronsUpDown,
7
+ FileCode2,
8
+ Link2,
9
+ } from 'lucide-react';
10
+ import React, { useCallback, useMemo, useState } from 'react';
11
+
12
+ import {
13
+ Tooltip,
14
+ TooltipContent,
15
+ TooltipTrigger,
16
+ SafeTooltipProvider,
17
+ } from '@djangocfg/ui-core/components';
18
+ import { cn } from '@djangocfg/ui-core/lib';
19
+
20
+ import { useEndpointDocContext } from '../context';
21
+ import { sectionKey, useEndpointDocStore } from '../store';
22
+ import type { SectionId } from '../types';
23
+
24
+ interface MetaActionsProps {
25
+ anchor: string;
26
+ endpointMarkdown: string;
27
+ /** Sections present on this endpoint — expand/collapse acts only
28
+ * on visible rows, never on catalogue items the card doesn't render. */
29
+ presentSections: readonly SectionId[];
30
+ }
31
+
32
+ interface IconButtonProps {
33
+ label: string;
34
+ onClick: () => void;
35
+ children: React.ReactNode;
36
+ active?: boolean;
37
+ }
38
+
39
+ /** Tight 24×24 icon button used across the meta row. Keeps the hit
40
+ * target compact so the row reads as a secondary metadata strip, not
41
+ * a toolbar that competes with the path for attention. */
42
+ function IconButton({ label, onClick, children, active }: IconButtonProps) {
43
+ return (
44
+ <Tooltip>
45
+ <TooltipTrigger asChild>
46
+ <button
47
+ type="button"
48
+ onClick={onClick}
49
+ aria-label={label}
50
+ className={cn(
51
+ 'shrink-0 h-6 w-6 inline-flex items-center justify-center rounded',
52
+ 'text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors',
53
+ active && 'text-emerald-500 hover:text-emerald-500',
54
+ )}
55
+ >
56
+ {children}
57
+ </button>
58
+ </TooltipTrigger>
59
+ <TooltipContent side="bottom" className="text-[11px]">
60
+ {label}
61
+ </TooltipContent>
62
+ </Tooltip>
63
+ );
64
+ }
65
+
66
+ /** Inline meta-row actions: copy link · copy markdown · expand/collapse
67
+ * all. Actions are always visible (corporate tool pattern) but sized
68
+ * down so the path on the next line stays the visual focal point. */
69
+ export function MetaActions({ anchor, endpointMarkdown, presentSections }: MetaActionsProps) {
70
+ const { endpointId } = useEndpointDocContext();
71
+ const expandAll = useEndpointDocStore((s) => s.expandAll);
72
+ const collapseAll = useEndpointDocStore((s) => s.collapseAll);
73
+ const openSections = useEndpointDocStore((s) => s.openSections);
74
+
75
+ const [justCopied, setJustCopied] = useState<'link' | 'md' | null>(null);
76
+ const flash = useCallback((which: 'link' | 'md') => {
77
+ setJustCopied(which);
78
+ setTimeout(() => setJustCopied(null), 1200);
79
+ }, []);
80
+
81
+ const mostlyOpen = useMemo(() => {
82
+ if (presentSections.length === 0) return false;
83
+ let openCount = 0;
84
+ for (const sid of presentSections) {
85
+ if (openSections[sectionKey(endpointId, sid)]) openCount += 1;
86
+ }
87
+ return openCount > presentSections.length / 2;
88
+ }, [openSections, presentSections, endpointId]);
89
+
90
+ const copyLink = useCallback(() => {
91
+ if (typeof window === 'undefined') return;
92
+ const url = `${window.location.origin}${window.location.pathname}#${anchor}`;
93
+ void navigator.clipboard?.writeText(url).then(() => flash('link'));
94
+ }, [anchor, flash]);
95
+
96
+ const copyMarkdown = useCallback(() => {
97
+ if (typeof window === 'undefined') return;
98
+ void navigator.clipboard?.writeText(endpointMarkdown).then(() => flash('md'));
99
+ }, [endpointMarkdown, flash]);
100
+
101
+ const toggleAll = useCallback(() => {
102
+ if (mostlyOpen) collapseAll(endpointId, presentSections);
103
+ else expandAll(endpointId, presentSections);
104
+ }, [mostlyOpen, collapseAll, expandAll, endpointId, presentSections]);
105
+
106
+ return (
107
+ <SafeTooltipProvider delayDuration={200}>
108
+ <div className="flex items-center gap-0.5">
109
+ <IconButton
110
+ label={justCopied === 'link' ? 'Copied!' : 'Copy link to endpoint'}
111
+ onClick={copyLink}
112
+ active={justCopied === 'link'}
113
+ >
114
+ {justCopied === 'link' ? (
115
+ <Check className="h-3.5 w-3.5" />
116
+ ) : (
117
+ <Link2 className="h-3.5 w-3.5" />
118
+ )}
119
+ </IconButton>
120
+ <IconButton
121
+ label={justCopied === 'md' ? 'Copied!' : 'Copy as Markdown (for AI)'}
122
+ onClick={copyMarkdown}
123
+ active={justCopied === 'md'}
124
+ >
125
+ {justCopied === 'md' ? (
126
+ <Check className="h-3.5 w-3.5" />
127
+ ) : (
128
+ <FileCode2 className="h-3.5 w-3.5" />
129
+ )}
130
+ </IconButton>
131
+ {presentSections.length >= 2 && (
132
+ <IconButton
133
+ label={mostlyOpen ? 'Collapse all sections' : 'Expand all sections'}
134
+ onClick={toggleAll}
135
+ >
136
+ {mostlyOpen ? (
137
+ <ChevronsDownUp className="h-3.5 w-3.5" />
138
+ ) : (
139
+ <ChevronsUpDown className="h-3.5 w-3.5" />
140
+ )}
141
+ </IconButton>
142
+ )}
143
+ </div>
144
+ </SafeTooltipProvider>
145
+ );
146
+ }
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+
3
+ // Thin re-export so the Header barrel file stays self-contained. The
4
+ // actual badge lives in ``shared/ui`` because it's used by the sidebar
5
+ // and the playground too — duplicating styles would drift.
6
+ export { MethodBadge } from '../../../shared/ui';
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import { relativePath } from '../../../shared/ui';
4
+
5
+ interface PathDisplayProps {
6
+ path: string;
7
+ }
8
+
9
+ /** The endpoint path as the visual focal point of the card. Renders in
10
+ * large monospace so readers scanning the page land on it first — the
11
+ * path is what distinguishes one endpoint from another, so it should
12
+ * be the biggest visible element short of the section title hierarchy.
13
+ *
14
+ * ``relativePath`` strips the resolved base URL so the docs always
15
+ * show the API-local path, not an absolute URL cluttered with host
16
+ * noise. */
17
+ export function PathDisplay({ path }: PathDisplayProps) {
18
+ return (
19
+ <code
20
+ className="block font-mono text-lg md:text-xl font-semibold text-foreground leading-tight"
21
+ style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
22
+ >
23
+ {relativePath(path)}
24
+ </code>
25
+ );
26
+ }
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { Play } from 'lucide-react';
4
+ import React, { useMemo } from 'react';
5
+
6
+ import { Button } from '@djangocfg/ui-core/components';
7
+
8
+ import { MarkdownMessage } from '../../../../../../components/markdown';
9
+ import type { ApiEndpoint } from '../../../../types';
10
+ import { endpointToMarkdown } from '../../../../utils/schemaExport';
11
+ import type { SectionId } from '../types';
12
+ import { MetaActions } from './MetaActions';
13
+ import { MethodBadge } from './MethodBadge';
14
+ import { PathDisplay } from './PathDisplay';
15
+
16
+ interface EndpointHeaderProps {
17
+ endpoint: ApiEndpoint;
18
+ anchor: string;
19
+ isLoadedInPlayground: boolean;
20
+ onTryIt: () => void;
21
+ /** Sections actually rendered on this endpoint — drives the
22
+ * expand/collapse-all icon in the meta row. */
23
+ presentSections: readonly SectionId[];
24
+ }
25
+
26
+ /** Card header, three stacked rows:
27
+ * 1. Meta — small utility strip: method badge · inline actions · Try-it.
28
+ * 2. Path — large monospace, the visual focus of the card.
29
+ * 3. Description — prose, aligned to the left edge under path.
30
+ *
31
+ * Splitting metadata from the path lets the path itself become the
32
+ * focal point — readers scan paths when scrolling, not badges. The
33
+ * meta row stays compact so it reads as "info about this endpoint"
34
+ * rather than "toolbar that competes for attention". */
35
+ export function EndpointHeader({
36
+ endpoint,
37
+ anchor,
38
+ isLoadedInPlayground,
39
+ onTryIt,
40
+ presentSections,
41
+ }: EndpointHeaderProps) {
42
+ // Memoise the markdown dump — only recomputes when the endpoint
43
+ // reference changes, not on unrelated re-renders of the subtree.
44
+ const endpointMd = useMemo(() => endpointToMarkdown(endpoint), [endpoint]);
45
+
46
+ return (
47
+ <header className="space-y-3">
48
+ {/* Row 1 — meta strip. Badge + inline icon actions on the
49
+ left, primary CTA on the right. Kept tight (24px tall)
50
+ so it doesn't visually compete with the path row below. */}
51
+ <div className="flex items-center gap-3 flex-wrap">
52
+ <div className="flex items-center gap-2 min-w-0">
53
+ <MethodBadge method={endpoint.method} />
54
+ <MetaActions
55
+ anchor={anchor}
56
+ endpointMarkdown={endpointMd}
57
+ presentSections={presentSections}
58
+ />
59
+ </div>
60
+ <Button
61
+ size="sm"
62
+ variant={isLoadedInPlayground ? 'secondary' : 'default'}
63
+ onClick={onTryIt}
64
+ className="ml-auto h-7 text-xs gap-1.5 px-2.5"
65
+ >
66
+ <Play className="h-3 w-3" />
67
+ {isLoadedInPlayground ? 'Loaded' : 'Try it'}
68
+ </Button>
69
+ </div>
70
+
71
+ {/* Row 2 — path as the visual focal point. Larger and more
72
+ prominent than it was when competing with the badge
73
+ and action icons inline. */}
74
+ <div className="min-w-0">
75
+ <PathDisplay path={endpoint.path} />
76
+ </div>
77
+
78
+ {/* Row 3 — description, aligned to the left edge under the
79
+ path. Text-sm so it reads as subtitle, not body copy. */}
80
+ {endpoint.description && (
81
+ <div className="text-muted-foreground text-sm">
82
+ <MarkdownMessage content={endpoint.description} />
83
+ </div>
84
+ )}
85
+ </header>
86
+ );
87
+ }
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import type { ApiEndpoint } from '../../../../types';
4
+ import { ParamRow } from './ParamRow';
5
+
6
+ interface ParamGroupProps {
7
+ /** Small uppercase label shown above the group (``Path``, ``Query``). */
8
+ label: string;
9
+ params: NonNullable<ApiEndpoint['parameters']>;
10
+ }
11
+
12
+ /** Labelled block of parameter rows. We split path vs query so the user
13
+ * can tell which values go in the URL template vs the query string at
14
+ * a glance — both are ``parameters`` in OpenAPI terms but they land in
15
+ * different places in the generated request. */
16
+ export function ParamGroup({ label, params }: ParamGroupProps) {
17
+ if (params.length === 0) return null;
18
+ return (
19
+ <div className="space-y-1.5">
20
+ <div className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/60 px-1">
21
+ {label}
22
+ </div>
23
+ <div className="divide-y border rounded-md overflow-hidden">
24
+ {params.map((p) => (
25
+ <ParamRow key={`${label}-${p.name}`} param={p} />
26
+ ))}
27
+ </div>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import type { ApiEndpoint } from '../../../../types';
4
+
5
+ type Param = NonNullable<ApiEndpoint['parameters']>[number];
6
+
7
+ interface ParamRowProps {
8
+ param: Param;
9
+ }
10
+
11
+ /** Single parameter row. Name + required marker + type + (optional)
12
+ * description. Wrapped in ``<div>`` rather than a table row so the
13
+ * list degrades to a stack on narrow widths without losing semantics. */
14
+ export function ParamRow({ param }: ParamRowProps) {
15
+ return (
16
+ <div className="px-3 py-2.5 bg-background space-y-1">
17
+ <div className="flex items-baseline gap-2 flex-wrap">
18
+ <code className="font-mono text-xs font-medium text-foreground">{param.name}</code>
19
+ {param.required && (
20
+ <span
21
+ title="Required"
22
+ className="text-[9px] text-destructive font-bold leading-none"
23
+ >
24
+ *
25
+ </span>
26
+ )}
27
+ <code className="font-mono text-[11px] text-muted-foreground/70">{param.type}</code>
28
+ </div>
29
+ {param.description && (
30
+ <p className="text-xs text-muted-foreground leading-relaxed break-words">
31
+ {param.description}
32
+ </p>
33
+ )}
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ import type { ApiEndpoint } from '../../../../types';
4
+ import { ParamGroup } from './ParamGroup';
5
+
6
+ interface ParametersProps {
7
+ pathParams: NonNullable<ApiEndpoint['parameters']>;
8
+ queryParams: NonNullable<ApiEndpoint['parameters']>;
9
+ }
10
+
11
+ /** Combined path + query parameters block. Each group renders only
12
+ * when non-empty; when both are empty the parent should not render
13
+ * the Section at all (we check up-stream so the Section badge shows a
14
+ * real count). */
15
+ export function Parameters({ pathParams, queryParams }: ParametersProps) {
16
+ return (
17
+ <div className="space-y-4">
18
+ <ParamGroup label="Path" params={pathParams} />
19
+ <ParamGroup label="Query" params={queryParams} />
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import type { ApiEndpoint } from '../../../../types';
4
+ import { SchemaFields } from '../SchemaFields';
5
+
6
+ interface RequestBodyProps {
7
+ body: NonNullable<ApiEndpoint['requestBody']>;
8
+ }
9
+
10
+ /** Request body preview. Shows a short type summary line (``object``,
11
+ * ``array<object>``, …) alongside the body description, then the
12
+ * tree-view of fields. The example payload is rendered below in its
13
+ * own section (code samples) because the user often wants to copy it
14
+ * independently from inspecting the shape. */
15
+ export function RequestBody({ body }: RequestBodyProps) {
16
+ const typeLabel = body.schema
17
+ ? body.type === 'array'
18
+ ? `array<${(body.schema as { items?: { type?: string } }).items?.type ?? 'object'}>`
19
+ : body.type
20
+ : body.type;
21
+
22
+ return (
23
+ <div className="space-y-3">
24
+ <div className="flex items-baseline gap-2 flex-wrap">
25
+ <code className="font-mono text-[11px] text-muted-foreground/80">{typeLabel}</code>
26
+ {body.description && (
27
+ <span className="text-xs text-muted-foreground">{body.description}</span>
28
+ )}
29
+ </div>
30
+ {body.schema && <SchemaFields schema={body.schema} />}
31
+ </div>
32
+ );
33
+ }