'use client'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import { keyBy } from 'lodash-es'; import { Skeleton } from '@djangocfg/ui-core/components'; import { useMediaQuery } from '@djangocfg/ui-core/hooks'; import useOpenApiSchema from '../../hooks/useOpenApiSchema'; import { useDocsUrlSync, type ParsedHash } from '../../hooks/useDocsUrlSync'; import { usePlaygroundContext } from '../../context/PlaygroundContext'; import type { ApiEndpoint } from '../../types'; import { EndpointDraftSync } from '../shared/EndpointDraftSync'; import { slugifySchemaId } from './anchor'; import { DocsSidebar } from './Sidebar'; import { DocsView, type DocsViewHandle } from './DocsView'; import { SlideInPlayground } from './SlideInPlayground'; import { TryItSheet } from './TryItSheet'; // ─── Root ───────────────────────────────────────────────────────────────────── export const DocsLayout: React.FC = () => { const { state, config, setSelectedEndpoint } = usePlaygroundContext(); // The docs layout has a sidebar + docs column that already eat ~260px // before the slide-in opens. Below 1024px the slide-in (min 720 wide) // leaves docs with <250px — unreadable — so we fall back to the // mobile-style ``TryItSheet`` on those viewports. const isDesktop = useMediaQuery('(min-width: 1024px)'); const isMobile = !isDesktop; const grouping = config.schemaGrouping ?? 'selector'; const preloadAll = grouping === 'sections'; const urlSyncEnabled = config.urlSync === undefined ? true : typeof config.urlSync === 'boolean' ? config.urlSync : Boolean(config.urlSync.enabled); const { endpoints, schemaInfo, rawSchema, resolvedBaseUrl, loading, error, schemas, currentSchema, setCurrentSchema, schemasData, } = useOpenApiSchema({ schemas: config.schemas, defaultSchemaId: config.defaultSchemaId, baseUrl: config.baseUrl, preloadAll, }); const [activeAnchor, setActiveAnchor] = useState(null); const [activeSchemaId, setActiveSchemaId] = useState(null); const [sheetOpen, setSheetOpen] = useState(false); const docsRef = useRef(null); // Desktop slide-in is driven directly by ``selectedEndpoint``. Keeping a // separate open-state would mean two sources of truth for the same // semantic — "which endpoint is loaded into the playground". const slideOpen = !isMobile && state.selectedEndpoint !== null; // Per-schema endpoint map for the sections sidebar. ``keyBy`` makes the // lookup O(1) at render time instead of scanning schemasData in each // CategoryBlock — a win for 10+ schemas. const endpointsBySchema = useMemo>(() => { if (grouping !== 'sections') return {}; const byId = keyBy(schemasData, (e) => e.source.id); const out: Record = {}; for (const src of schemas) out[src.id] = byId[src.id]?.endpoints ?? []; return out; }, [grouping, schemasData, schemas]); const handleTry = useCallback( (ep: ApiEndpoint) => { setSelectedEndpoint(ep); if (isMobile) setSheetOpen(true); }, [isMobile, setSelectedEndpoint], ); const handleCloseSlide = useCallback(() => { setSelectedEndpoint(null); }, [setSelectedEndpoint]); const handleNavigate = useCallback( (anchor: string, schemaId?: string | null) => { // In selector mode a schema switch may be required before the // anchor exists in the DOM — defer the scroll until the next // paint so ``useOpenApiSchema`` has a chance to swap endpoints. if (schemaId && schemaId !== currentSchema?.id && grouping === 'selector') { setCurrentSchema(schemaId); requestAnimationFrame(() => { docsRef.current?.scrollToAnchor(anchor); }); return; } docsRef.current?.scrollToAnchor(anchor); }, [currentSchema?.id, grouping, setCurrentSchema], ); const handleActiveChange = useCallback((anchor: string | null, schemaId: string | null) => { setActiveAnchor(anchor); setActiveSchemaId(schemaId); }, []); // URL sync: read hash on mount / popstate → apply; write hash when // scrollspy updates. Only the *effective* active schema goes into the // hash — in ``selector`` mode it's the combobox value, in ``sections`` // mode it's whichever schema the scrollspy is currently inside. const effectiveSchemaId = grouping === 'sections' ? activeSchemaId : currentSchema?.id ?? null; const handleHashTarget = useCallback( (target: ParsedHash) => { if (!target.schemaId && !target.anchor) return; // Schema-id segment may be either the raw id or a slug — match // both so copy-pasted URLs survive id changes that don't affect // the slug. First match wins. const matched = target.schemaId ? schemas.find((s) => s.id === target.schemaId || slugifySchemaId(s.id) === target.schemaId) : null; const needsSchemaSwitch = matched && grouping === 'selector' && matched.id !== currentSchema?.id; if (needsSchemaSwitch) { setCurrentSchema(matched.id); } if (target.anchor) { const anchor = target.anchor; // Wait one frame when a switch happened so the new DOM exists. if (needsSchemaSwitch) { requestAnimationFrame(() => { docsRef.current?.scrollToAnchor(anchor); }); } else { docsRef.current?.scrollToAnchor(anchor); } } }, [schemas, grouping, currentSchema?.id, setCurrentSchema], ); useDocsUrlSync({ enabled: urlSyncEnabled, currentSchemaId: effectiveSchemaId, activeAnchor, onHashTarget: handleHashTarget, }); // ─── Loading / error branches ───────────────────────────────────────── if (loading) { return (
{Array.from({ length: 12 }).map((_, i) => ( ))}
{Array.from({ length: 3 }).map((_, i) => (
))}
); } if (error) { return (

Failed to load schema: {error}

); } // ─── Mobile: sidebar + docs only, playground opens in sheet ─────────── if (isMobile) { return (
{grouping === 'sections' ? ( ) : ( )}
); } // ─── Desktop ────────────────────────────────────────────────────────── return (
{grouping === 'sections' ? ( ) : ( )} {/* SidePanel renders into via portal, so it floats above the whole layout (sidebar + navbar included). */}
); };