'use client'; import { Check, ChevronDown, Sparkles } from 'lucide-react'; import React, { useCallback, useMemo, useState } from 'react'; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@djangocfg/ui-core/components'; import { toast } from '@djangocfg/ui-core/hooks'; import type { ApiEndpoint, OpenApiSchema } from '../../types'; import { formatBytes, toCompactJson, toMarkdown, toRawJson, } from '../../utils/schemaExport'; type Flavour = 'markdown' | 'compact' | 'raw'; const FLAVOUR_LABELS: Record = { markdown: { title: 'Markdown for LLM', hint: 'Endpoints + params as prose. Smallest.', }, compact: { title: 'Compact JSON', hint: 'Dereferenced, no examples, minified.', }, raw: { title: 'Raw JSON', hint: 'Full OpenAPI document with $refs.', }, }; interface SchemaCopyMenuProps { schema: OpenApiSchema | null; endpoints: ApiEndpoint[]; /** Resolved base URL that gets embedded into the copy so the AI * receives working URLs, not the ones originally in ``schema.servers``. */ baseUrl?: string; /** Trigger appearance. * - ``button`` (default) — labelled pill with icon + chevron. * - ``icon`` — square ghost button, used in tight spots like the * sidebar header where there is no room for "Copy for AI". * - ``footer`` — full-width secondary CTA, designed to sit at the * bottom of the sidebar. Menu opens upward so it stays inside * the panel. */ variant?: 'button' | 'icon' | 'footer'; /** Where the dropdown content opens. Defaults to ``right`` for the * inline triggers; ``footer`` overrides to ``top``. */ side?: 'right' | 'top' | 'bottom' | 'left'; } /** * Per-schema copy dropdown. Shows three flavours tuned for different LLM * use-cases. We compute each flavour lazily (only on click) because * dereferencing + stringifying a large schema can be non-trivial — sizes * are displayed after the first successful copy, via a tiny cache. */ export function SchemaCopyMenu({ schema, endpoints, baseUrl, variant = 'button', side }: SchemaCopyMenuProps) { const [sizeCache, setSizeCache] = useState>>({}); const [justCopied, setJustCopied] = useState(null); const [open, setOpen] = useState(false); const isReady = schema !== null && endpoints.length > 0; const build = useCallback( (flavour: Flavour): string => { if (!schema) return ''; if (flavour === 'markdown') return toMarkdown(schema, endpoints, baseUrl); if (flavour === 'compact') return toCompactJson(schema, baseUrl); return toRawJson(schema, baseUrl); }, [schema, endpoints, baseUrl], ); const handleCopy = useCallback( async (flavour: Flavour) => { if (!isReady) return; const text = build(flavour); const label = FLAVOUR_LABELS[flavour].title; try { await navigator.clipboard.writeText(text); const size = formatBytes(text); setSizeCache((prev) => ({ ...prev, [flavour]: size })); setJustCopied(flavour); setTimeout(() => setJustCopied(null), 1500); setOpen(false); toast.success(`Copied ${label}`, { description: size }); } catch (err) { const message = err instanceof Error ? err.message : 'Clipboard permission denied'; toast.error('Copy failed', { description: message }); } }, [build, isReady], ); const flavours = useMemo(() => ['markdown', 'compact', 'raw'], []); const resolvedSide = side ?? (variant === 'footer' ? 'top' : 'right'); const resolvedAlign = variant === 'footer' ? 'center' : 'start'; return ( {variant === 'icon' ? ( ) : variant === 'footer' ? ( ) : ( )} Copy schema {flavours.map((f) => { const label = FLAVOUR_LABELS[f]; const size = sizeCache[f]; const isDone = justCopied === f; return ( { e.preventDefault(); void handleCopy(f); }} className="flex flex-col items-start gap-0.5 py-2 cursor-pointer" >
{label.title} {isDone ? ( Copied ) : size ? ( {size} ) : null}
{label.hint}
); })}
); }