@djangocfg/ui-tools 2.1.285 → 2.1.287

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/DocsLayout-BCVU6TTX.cjs +2027 -0
  2. package/dist/DocsLayout-BCVU6TTX.cjs.map +1 -0
  3. package/dist/DocsLayout-ERETJLLV.mjs +2020 -0
  4. package/dist/DocsLayout-ERETJLLV.mjs.map +1 -0
  5. package/dist/{PlaygroundLayout-O52C6HK5.css → DocsLayout-MBFIB4NO.css} +1 -1
  6. package/dist/{PrettyCode.client-SGDGQTYT.cjs → PrettyCode.client-5GABIN2I.cjs} +57 -35
  7. package/dist/PrettyCode.client-5GABIN2I.cjs.map +1 -0
  8. package/dist/{PrettyCode.client-DW5LTG47.mjs → PrettyCode.client-IZTXXYHG.mjs} +57 -35
  9. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +1 -0
  10. package/dist/chunk-IULI4XII.cjs +1129 -0
  11. package/dist/chunk-IULI4XII.cjs.map +1 -0
  12. package/dist/chunk-VZGQC3NG.mjs +1100 -0
  13. package/dist/chunk-VZGQC3NG.mjs.map +1 -0
  14. package/dist/index.cjs +88 -552
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +18 -6
  17. package/dist/index.d.ts +18 -6
  18. package/dist/index.mjs +25 -496
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +6 -6
  21. package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +6 -0
  22. package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
  23. package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
  24. package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
  25. package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
  26. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
  27. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
  28. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
  29. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
  30. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -0
  31. package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
  32. package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
  33. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
  34. package/src/tools/OpenapiViewer/README.md +104 -51
  35. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
  39. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
  40. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
  41. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
  42. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
  43. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
  44. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
  45. package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
  46. package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
  47. package/src/tools/OpenapiViewer/components/index.ts +5 -2
  48. package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
  49. package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
  50. package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
  51. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
  52. package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
  53. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
  54. package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
  55. package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
  56. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
  57. package/src/tools/OpenapiViewer/index.tsx +3 -7
  58. package/src/tools/OpenapiViewer/lazy.tsx +6 -27
  59. package/src/tools/OpenapiViewer/types.ts +44 -0
  60. package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
  61. package/src/tools/OpenapiViewer/utils/index.ts +3 -1
  62. package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
  63. package/src/tools/OpenapiViewer/utils/url.ts +202 -0
  64. package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
  65. package/src/tools/PrettyCode/index.tsx +6 -0
  66. package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
  67. package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
  68. package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
  69. package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
  70. package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
  71. package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
  72. package/dist/chunk-5FKE7OME.cjs +0 -369
  73. package/dist/chunk-5FKE7OME.cjs.map +0 -1
  74. package/dist/chunk-BKWDHJKF.mjs +0 -356
  75. package/dist/chunk-BKWDHJKF.mjs.map +0 -1
  76. package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
  77. package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
  78. /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
  79. /package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ResponsePanel.tsx +0 -0
@@ -0,0 +1,211 @@
1
+ 'use client';
2
+
3
+ import { Search } from 'lucide-react';
4
+ import React, { useEffect, useMemo, useState } from 'react';
5
+
6
+ import {
7
+ Combobox,
8
+ Input,
9
+ Tooltip,
10
+ TooltipContent,
11
+ TooltipTrigger,
12
+ } from '@djangocfg/ui-core/components';
13
+ import { cn } from '@djangocfg/ui-core/lib';
14
+
15
+ import type { ApiEndpoint, OpenApiInfo, SchemaSource } from '../../types';
16
+ import { deduplicateEndpoints } from '../../utils/versionManager';
17
+ import { MethodBadge, ScrollArea } from '../shared/ui';
18
+ import { endpointAnchor } from './anchor';
19
+ import { longestCommonPrefix, sidebarLabel, sidebarTooltip } from './sidebarLabel';
20
+
21
+ type Group = {
22
+ category: string;
23
+ endpoints: ApiEndpoint[];
24
+ /** Longest ``/``-aligned prefix shared by every endpoint in this group.
25
+ * Stripped from labels that fall back to showing the path. */
26
+ commonPrefix: string;
27
+ };
28
+
29
+ const METHOD_ORDER: Record<string, number> = {
30
+ GET: 0, POST: 1, PUT: 2, PATCH: 3, DELETE: 4,
31
+ };
32
+
33
+ function groupEndpoints(list: ApiEndpoint[]): Group[] {
34
+ const map = new Map<string, ApiEndpoint[]>();
35
+ for (const ep of list) {
36
+ const arr = map.get(ep.category) ?? [];
37
+ arr.push(ep);
38
+ map.set(ep.category, arr);
39
+ }
40
+ const groups: Group[] = Array.from(map.entries()).map(([category, endpoints]) => ({
41
+ category,
42
+ endpoints: [...endpoints].sort((a, b) => {
43
+ const byPath = a.path.localeCompare(b.path);
44
+ if (byPath !== 0) return byPath;
45
+ return (METHOD_ORDER[a.method] ?? 99) - (METHOD_ORDER[b.method] ?? 99);
46
+ }),
47
+ commonPrefix: longestCommonPrefix(endpoints.map((e) => e.path)),
48
+ }));
49
+ // Alphabetical, but "Other" sinks to the bottom.
50
+ groups.sort((a, b) => {
51
+ if (a.category === 'Other') return 1;
52
+ if (b.category === 'Other') return -1;
53
+ return a.category.localeCompare(b.category);
54
+ });
55
+ return groups;
56
+ }
57
+
58
+ export interface DocsSidebarProps {
59
+ info: OpenApiInfo | null;
60
+ endpoints: ApiEndpoint[];
61
+ schemas: SchemaSource[];
62
+ currentSchemaId: string | null;
63
+ onSchemaChange: (id: string) => void;
64
+ activeEndpointId: string | null;
65
+ selectedVersion: string;
66
+ onNavigate: (anchor: string) => void;
67
+ }
68
+
69
+ export function DocsSidebar({
70
+ info,
71
+ endpoints,
72
+ schemas,
73
+ currentSchemaId,
74
+ onSchemaChange,
75
+ activeEndpointId,
76
+ selectedVersion,
77
+ onNavigate,
78
+ }: DocsSidebarProps) {
79
+ const [search, setSearch] = useState('');
80
+ const [debounced, setDebounced] = useState('');
81
+
82
+ useEffect(() => {
83
+ const id = setTimeout(() => setDebounced(search), 120);
84
+ return () => clearTimeout(id);
85
+ }, [search]);
86
+
87
+ const filteredGroups = useMemo(() => {
88
+ let list = deduplicateEndpoints(endpoints, selectedVersion);
89
+ if (debounced) {
90
+ const q = debounced.toLowerCase();
91
+ list = list.filter((e) =>
92
+ e.summary.toLowerCase().includes(q) ||
93
+ e.name.toLowerCase().includes(q) ||
94
+ e.description.toLowerCase().includes(q) ||
95
+ e.path.toLowerCase().includes(q),
96
+ );
97
+ }
98
+ return groupEndpoints(list);
99
+ }, [endpoints, debounced, selectedVersion]);
100
+
101
+ const schemaOptions = useMemo(
102
+ () => schemas.map((s) => ({ value: s.id, label: s.name })),
103
+ [schemas],
104
+ );
105
+ const hasMultipleSchemas = schemas.length > 1;
106
+ const apiTitle = info?.title ?? 'API Reference';
107
+
108
+ return (
109
+ <aside className="flex flex-col min-h-0 border-r bg-muted/10">
110
+ {/* Brand row */}
111
+ <div className="shrink-0 border-b px-4 h-12 flex items-center gap-2">
112
+ <span className="text-[13px] font-semibold text-foreground truncate">
113
+ {apiTitle}
114
+ </span>
115
+ {info?.version && (
116
+ <span className="font-mono text-[10px] text-muted-foreground/70 shrink-0">
117
+ v{info.version}
118
+ </span>
119
+ )}
120
+ </div>
121
+
122
+ {/* Controls */}
123
+ <div className="shrink-0 border-b px-3 py-3 space-y-2">
124
+ {hasMultipleSchemas && (
125
+ <Combobox
126
+ options={schemaOptions}
127
+ value={currentSchemaId ?? ''}
128
+ onValueChange={(id) => id && onSchemaChange(id)}
129
+ placeholder="Select API"
130
+ searchPlaceholder="Search APIs…"
131
+ emptyText="No APIs found"
132
+ className="w-full h-8 text-xs"
133
+ />
134
+ )}
135
+ <div className="relative">
136
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50 pointer-events-none" />
137
+ <Input
138
+ placeholder="Search endpoints…"
139
+ value={search}
140
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
141
+ className="pl-8 h-8 text-xs"
142
+ />
143
+ </div>
144
+ </div>
145
+
146
+ <ScrollArea>
147
+ {filteredGroups.length === 0 ? (
148
+ <div className="py-10 px-4 text-center text-xs text-muted-foreground">
149
+ {debounced
150
+ ? `No endpoints match "${debounced}"`
151
+ : 'No endpoints in this schema'}
152
+ </div>
153
+ ) : (
154
+ <nav className="py-2">
155
+ {filteredGroups.map((group) => (
156
+ <div key={group.category} className="mb-4 last:mb-2">
157
+ <div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/50 select-none">
158
+ {group.category}
159
+ </div>
160
+ <div>
161
+ {group.endpoints.map((ep) => {
162
+ const anchor = endpointAnchor(ep);
163
+ const isActive = activeEndpointId === anchor;
164
+ const label = sidebarLabel(ep, group.commonPrefix);
165
+ const tooltip = sidebarTooltip(ep);
166
+ // Summary → sans-serif (reads like an outline).
167
+ // Path-tail fallback → mono (reads like code).
168
+ const useMono = !ep.summary;
169
+ return (
170
+ <Tooltip key={`${ep.method}-${ep.path}`} delayDuration={350}>
171
+ <TooltipTrigger asChild>
172
+ <button
173
+ onClick={() => onNavigate(anchor)}
174
+ aria-current={isActive ? 'location' : undefined}
175
+ className={cn(
176
+ 'relative group w-full text-left flex items-center gap-2 pl-4 pr-3 py-1.5 transition-colors',
177
+ isActive
178
+ ? 'bg-primary/10 text-foreground'
179
+ : 'hover:bg-muted/40 text-foreground/75 hover:text-foreground',
180
+ )}
181
+ >
182
+ {isActive && (
183
+ <span className="absolute left-0 top-1 bottom-1 w-0.5 rounded-r bg-primary" />
184
+ )}
185
+ <MethodBadge method={ep.method} />
186
+ <span
187
+ className={cn(
188
+ 'truncate leading-tight flex-1 min-w-0',
189
+ useMono ? 'font-mono text-[11px]' : 'text-[12px]',
190
+ isActive && 'text-foreground font-medium',
191
+ )}
192
+ >
193
+ {label}
194
+ </span>
195
+ </button>
196
+ </TooltipTrigger>
197
+ <TooltipContent side="right" align="center" className="font-mono text-[11px]">
198
+ {tooltip}
199
+ </TooltipContent>
200
+ </Tooltip>
201
+ );
202
+ })}
203
+ </div>
204
+ </div>
205
+ ))}
206
+ </nav>
207
+ )}
208
+ </ScrollArea>
209
+ </aside>
210
+ );
211
+ }
@@ -0,0 +1,101 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { SidePanel } from '@djangocfg/ui-core/components';
6
+ import { cn } from '@djangocfg/ui-core/lib';
7
+
8
+ import { usePlaygroundContext } from '../../context/PlaygroundContext';
9
+ import { RequestPanel } from '../shared/RequestPanel';
10
+ import { ResponsePanel } from '../shared/ResponsePanel';
11
+ import { SendButton } from '../shared/SendButton';
12
+ import { MethodBadge, Panel, relativePath } from '../shared/ui';
13
+
14
+ interface SlideInPlaygroundProps {
15
+ open: boolean;
16
+ onClose: () => void;
17
+ }
18
+
19
+ // Width when only Request is visible vs. when Response is also rendered.
20
+ // ``clamp(min, preferred, max)`` lets the panel scale with the viewport
21
+ // instead of sitting at a fixed size that looks tiny on ultra-wide or
22
+ // cramped on laptops.
23
+ //
24
+ // Narrow (Request only) — form content, no need to ever exceed ~480px.
25
+ // 1280 viewport → 384 | 1440 → 432 | 1920 → 480 (cap) | 2560 → 480 (cap)
26
+ //
27
+ // Wide (Request + Response) — response can be huge JSON/HTML; give it room.
28
+ // 1280 → 768 | 1440 → 864 | 1920 → 1152 | 2560 → 1280 (cap) | 3840 → 1280 (cap)
29
+ //
30
+ // Transition between the two values is animated by SidePanel.Content
31
+ // (both ``transform`` and ``width`` listed there). clamp() interpolates
32
+ // smoothly during that transition, no extra plumbing needed.
33
+ const WIDTH_NARROW = 'clamp(380px, 30vw, 480px)';
34
+ const WIDTH_WIDE = 'clamp(720px, 60vw, 1280px)';
35
+
36
+ /**
37
+ * Right-side slide-in playground. Two-column layout appears once the
38
+ * user has a response in hand — before that, only the Request form is
39
+ * shown and the panel stays narrow so docs have room to breathe.
40
+ *
41
+ * State transitions:
42
+ * - no endpoint ⇒ panel not rendered
43
+ * - endpoint, no resp ⇒ narrow (Request only)
44
+ * - endpoint + loading ⇒ wide (Request + Response spinner)
45
+ * - endpoint + resp ⇒ wide (Request + Response)
46
+ *
47
+ * Selecting a different endpoint clears ``response`` in the reducer,
48
+ * so the panel smoothly collapses back to narrow.
49
+ */
50
+ export function SlideInPlayground({ open, onClose }: SlideInPlaygroundProps) {
51
+ const { state } = usePlaygroundContext();
52
+ const ep = state.selectedEndpoint;
53
+ const showResponse = state.response !== null || state.loading;
54
+ const width = showResponse ? WIDTH_WIDE : WIDTH_NARROW;
55
+
56
+ return (
57
+ <SidePanel open={open} onOpenChange={(v) => !v && onClose()} side="right">
58
+ <SidePanel.Content width={width} className="max-w-[95vw]">
59
+ <SidePanel.Header>
60
+ <SidePanel.Title>Playground</SidePanel.Title>
61
+ {ep && (
62
+ <div className="flex items-center gap-2 min-w-0 flex-1">
63
+ <MethodBadge method={ep.method} />
64
+ <code className="font-mono text-[11px] text-muted-foreground truncate">
65
+ {relativePath(ep.path)}
66
+ </code>
67
+ </div>
68
+ )}
69
+ <SidePanel.Close className="ml-auto" />
70
+ </SidePanel.Header>
71
+
72
+ {/* Body: 1 or 2 columns depending on whether we have a
73
+ response to show. ``divide-x`` gives a 1px seam
74
+ between the panes so they read as distinct surfaces. */}
75
+ <SidePanel.Body
76
+ className={cn(
77
+ 'overflow-hidden grid divide-x transition-[grid-template-columns] duration-250',
78
+ showResponse
79
+ ? 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)]'
80
+ : 'grid-cols-1',
81
+ )}
82
+ >
83
+ <Panel>
84
+ <RequestPanel />
85
+ </Panel>
86
+ {showResponse && (
87
+ <Panel>
88
+ <ResponsePanel />
89
+ </Panel>
90
+ )}
91
+ </SidePanel.Body>
92
+
93
+ {ep && (
94
+ <SidePanel.Footer className="px-4 py-3">
95
+ <SendButton />
96
+ </SidePanel.Footer>
97
+ )}
98
+ </SidePanel.Content>
99
+ </SidePanel>
100
+ );
101
+ }
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import {
6
+ ResponsiveSheet,
7
+ ResponsiveSheetContent,
8
+ ResponsiveSheetHeader,
9
+ ResponsiveSheetTitle,
10
+ } from '@djangocfg/ui-core/components';
11
+
12
+ import { usePlaygroundContext } from '../../context/PlaygroundContext';
13
+ import { RequestPanel } from '../shared/RequestPanel';
14
+ import { ResponsePanel } from '../shared/ResponsePanel';
15
+ import { SendButton } from '../shared/SendButton';
16
+
17
+ interface TryItSheetProps {
18
+ open: boolean;
19
+ onOpenChange: (open: boolean) => void;
20
+ }
21
+
22
+ /**
23
+ * Mobile/tablet fallback: the slide-in playground doesn't fit, so we
24
+ * open Request + Response inside a ResponsiveSheet (bottom drawer on
25
+ * mobile, side sheet on desktop). Response stays hidden until a
26
+ * request has been sent — matching the desktop ``SlideInPlayground``
27
+ * behaviour.
28
+ */
29
+ export function TryItSheet({ open, onOpenChange }: TryItSheetProps) {
30
+ const { state } = usePlaygroundContext();
31
+ const showResponse = state.response !== null || state.loading;
32
+
33
+ return (
34
+ <ResponsiveSheet open={open} onOpenChange={onOpenChange}>
35
+ <ResponsiveSheetContent className="sm:max-w-xl flex flex-col h-full p-0">
36
+ <ResponsiveSheetHeader className="px-4 py-3 border-b shrink-0">
37
+ <ResponsiveSheetTitle className="text-sm">Playground</ResponsiveSheetTitle>
38
+ </ResponsiveSheetHeader>
39
+ <div className="flex-1 min-h-0 flex flex-col divide-y">
40
+ <div className={showResponse ? 'flex-1 min-h-0 flex flex-col' : 'flex-1 min-h-0 flex flex-col'}>
41
+ <RequestPanel />
42
+ </div>
43
+ {showResponse && (
44
+ <div className="flex-1 min-h-0 flex flex-col">
45
+ <ResponsePanel />
46
+ </div>
47
+ )}
48
+ </div>
49
+ {state.selectedEndpoint && (
50
+ <div className="shrink-0 border-t px-4 py-3 bg-background/95 backdrop-blur-sm">
51
+ <SendButton />
52
+ </div>
53
+ )}
54
+ </ResponsiveSheetContent>
55
+ </ResponsiveSheet>
56
+ );
57
+ }
@@ -0,0 +1,11 @@
1
+ import type { ApiEndpoint } from '../../types';
2
+
3
+ export function endpointAnchor(ep: Pick<ApiEndpoint, 'method' | 'path'>): string {
4
+ const slug = ep.path
5
+ .replace(/^https?:\/\/[^/]+/, '')
6
+ .replace(/[{}]/g, '')
7
+ .replace(/[^a-zA-Z0-9]+/g, '-')
8
+ .replace(/^-+|-+$/g, '')
9
+ .toLowerCase();
10
+ return `ep-${ep.method.toLowerCase()}-${slug}`;
11
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared grouping / sort for the docs layout.
3
+ *
4
+ * Sidebar and the docs longread MUST use the same order — otherwise
5
+ * scrollspy highlights jump around as the user scrolls (the sidebar's
6
+ * ordered list doesn't match the visual order of sections in the docs).
7
+ * This module is the single source of truth for that ordering.
8
+ */
9
+
10
+ import type { ApiEndpoint } from '../../types';
11
+ import { longestCommonPrefix } from './sidebarLabel';
12
+
13
+ export type EndpointGroup = {
14
+ category: string;
15
+ endpoints: ApiEndpoint[];
16
+ /** Longest ``/``-aligned prefix shared by every endpoint in this
17
+ * group. Used by the sidebar to strip the redundant group prefix
18
+ * from fallback labels. */
19
+ commonPrefix: string;
20
+ };
21
+
22
+ const METHOD_ORDER: Record<string, number> = {
23
+ GET: 0,
24
+ POST: 1,
25
+ PUT: 2,
26
+ PATCH: 3,
27
+ DELETE: 4,
28
+ };
29
+
30
+ /**
31
+ * Stable, deterministic ordering so two different renders with the
32
+ * same endpoint list always produce the same visual sequence.
33
+ *
34
+ * Groups: alphabetical by tag/category, with ``Other`` pinned to the
35
+ * bottom (spec-less endpoints should not steal the top slot).
36
+ *
37
+ * Within a group: endpoints sorted by path first (so related resources
38
+ * cluster), then by HTTP method (read → write → delete).
39
+ */
40
+ export function groupEndpoints(list: ApiEndpoint[]): EndpointGroup[] {
41
+ const map = new Map<string, ApiEndpoint[]>();
42
+ for (const ep of list) {
43
+ const arr = map.get(ep.category) ?? [];
44
+ arr.push(ep);
45
+ map.set(ep.category, arr);
46
+ }
47
+
48
+ const groups: EndpointGroup[] = Array.from(map.entries()).map(([category, endpoints]) => ({
49
+ category,
50
+ endpoints: [...endpoints].sort((a, b) => {
51
+ const byPath = a.path.localeCompare(b.path);
52
+ if (byPath !== 0) return byPath;
53
+ return (METHOD_ORDER[a.method] ?? 99) - (METHOD_ORDER[b.method] ?? 99);
54
+ }),
55
+ commonPrefix: longestCommonPrefix(endpoints.map((e) => e.path)),
56
+ }));
57
+
58
+ groups.sort((a, b) => {
59
+ if (a.category === 'Other') return 1;
60
+ if (b.category === 'Other') return -1;
61
+ return a.category.localeCompare(b.category);
62
+ });
63
+ return groups;
64
+ }
65
+
66
+ /** Flatten grouped endpoints back into a linear list that preserves
67
+ * group order + within-group order. This is the canonical order for
68
+ * both the sidebar and the docs longread. */
69
+ export function flattenGrouped(groups: EndpointGroup[]): ApiEndpoint[] {
70
+ return groups.flatMap((g) => g.endpoints);
71
+ }
@@ -0,0 +1,166 @@
1
+ 'use client';
2
+
3
+ import React, { useCallback, useRef, useState } from 'react';
4
+
5
+ import { Skeleton, TooltipProvider } from '@djangocfg/ui-core/components';
6
+ import { useMediaQuery } from '@djangocfg/ui-core/hooks';
7
+
8
+ import useOpenApiSchema from '../../hooks/useOpenApiSchema';
9
+ import { usePlaygroundContext } from '../../context/PlaygroundContext';
10
+ import type { ApiEndpoint } from '../../types';
11
+ import { EndpointDraftSync } from '../shared/EndpointDraftSync';
12
+ import { DocsSidebar } from './Sidebar';
13
+ import { DocsView, type DocsViewHandle } from './DocsView';
14
+ import { SlideInPlayground } from './SlideInPlayground';
15
+ import { TryItSheet } from './TryItSheet';
16
+
17
+ // ─── Root ─────────────────────────────────────────────────────────────────────
18
+
19
+ export const DocsLayout: React.FC = () => {
20
+ const { state, config, setSelectedEndpoint } = usePlaygroundContext();
21
+ // The docs layout has a sidebar + docs column that already eat ~260px
22
+ // before the slide-in opens. Below 1024px the slide-in (min 720 wide)
23
+ // leaves docs with <250px — unreadable — so we fall back to the
24
+ // mobile-style ``TryItSheet`` on those viewports.
25
+ const isDesktop = useMediaQuery('(min-width: 1024px)');
26
+ const isMobile = !isDesktop;
27
+ const {
28
+ endpoints,
29
+ schemaInfo,
30
+ rawSchema,
31
+ resolvedBaseUrl,
32
+ loading,
33
+ error,
34
+ schemas,
35
+ currentSchema,
36
+ setCurrentSchema,
37
+ } = useOpenApiSchema({
38
+ schemas: config.schemas,
39
+ defaultSchemaId: config.defaultSchemaId,
40
+ baseUrl: config.baseUrl,
41
+ });
42
+
43
+ const [activeAnchor, setActiveAnchor] = useState<string | null>(null);
44
+ const [sheetOpen, setSheetOpen] = useState(false);
45
+ const docsRef = useRef<DocsViewHandle | null>(null);
46
+
47
+ // Desktop slide-in is driven directly by ``selectedEndpoint``. Keeping a
48
+ // separate open-state would mean two sources of truth for the same
49
+ // semantic — "which endpoint is loaded into the playground".
50
+ const slideOpen = !isMobile && state.selectedEndpoint !== null;
51
+
52
+ const handleTry = useCallback(
53
+ (ep: ApiEndpoint) => {
54
+ setSelectedEndpoint(ep);
55
+ if (isMobile) setSheetOpen(true);
56
+ },
57
+ [isMobile, setSelectedEndpoint],
58
+ );
59
+
60
+ const handleCloseSlide = useCallback(() => {
61
+ setSelectedEndpoint(null);
62
+ }, [setSelectedEndpoint]);
63
+
64
+ const handleNavigate = useCallback((anchor: string) => {
65
+ docsRef.current?.scrollToAnchor(anchor);
66
+ }, []);
67
+ // Esc handling lives inside SidePanel itself — no duplicate listener.
68
+
69
+ if (loading) {
70
+ return (
71
+ <div
72
+ className="grid grid-cols-[260px_1fr] min-h-0 overflow-hidden"
73
+ style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
74
+ >
75
+ <div className="border-r p-3 space-y-1.5">
76
+ {Array.from({ length: 12 }).map((_, i) => (
77
+ <Skeleton key={i} className="h-8 w-full rounded" />
78
+ ))}
79
+ </div>
80
+ <div className="p-8 space-y-4">
81
+ <Skeleton className="h-8 w-1/2" />
82
+ <Skeleton className="h-4 w-full" />
83
+ <Skeleton className="h-4 w-3/4" />
84
+ <div className="mt-8 space-y-6">
85
+ {Array.from({ length: 3 }).map((_, i) => (
86
+ <div key={i} className="space-y-3">
87
+ <Skeleton className="h-6 w-1/3" />
88
+ <Skeleton className="h-20 w-full" />
89
+ </div>
90
+ ))}
91
+ </div>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ if (error) {
98
+ return (
99
+ <div
100
+ className="flex items-center justify-center p-8"
101
+ style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
102
+ >
103
+ <p className="text-sm text-destructive">Failed to load schema: {error}</p>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ // Mobile/tablet: sidebar + docs only, playground opens in sheet.
109
+ if (isMobile) {
110
+ return (
111
+ <div
112
+ className="flex flex-col overflow-hidden"
113
+ style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
114
+ >
115
+ <EndpointDraftSync schemaId={currentSchema?.id ?? null} />
116
+ <DocsView
117
+ ref={docsRef}
118
+ info={schemaInfo}
119
+ rawSchema={rawSchema}
120
+ resolvedBaseUrl={resolvedBaseUrl}
121
+ endpoints={endpoints}
122
+ selectedVersion={state.selectedVersion}
123
+ loadedEndpoint={state.selectedEndpoint}
124
+ onTryEndpoint={handleTry}
125
+ onActiveChange={setActiveAnchor}
126
+ />
127
+ <TryItSheet open={sheetOpen} onOpenChange={setSheetOpen} />
128
+ </div>
129
+ );
130
+ }
131
+
132
+ return (
133
+ <TooltipProvider delayDuration={350}>
134
+ <div
135
+ className="grid grid-cols-[260px_minmax(0,1fr)] min-h-0 overflow-hidden"
136
+ style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
137
+ >
138
+ <EndpointDraftSync schemaId={currentSchema?.id ?? null} />
139
+ <DocsSidebar
140
+ info={schemaInfo}
141
+ endpoints={endpoints}
142
+ schemas={schemas}
143
+ currentSchemaId={currentSchema?.id ?? null}
144
+ onSchemaChange={setCurrentSchema}
145
+ activeEndpointId={activeAnchor}
146
+ selectedVersion={state.selectedVersion}
147
+ onNavigate={handleNavigate}
148
+ />
149
+ <DocsView
150
+ ref={docsRef}
151
+ info={schemaInfo}
152
+ rawSchema={rawSchema}
153
+ resolvedBaseUrl={resolvedBaseUrl}
154
+ endpoints={endpoints}
155
+ selectedVersion={state.selectedVersion}
156
+ loadedEndpoint={state.selectedEndpoint}
157
+ onTryEndpoint={handleTry}
158
+ onActiveChange={setActiveAnchor}
159
+ />
160
+ {/* SidePanel renders into <body> via portal, so it floats
161
+ above the whole layout (sidebar + navbar included). */}
162
+ <SlideInPlayground open={slideOpen} onClose={handleCloseSlide} />
163
+ </div>
164
+ </TooltipProvider>
165
+ );
166
+ };