Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>OpenAPI Schema Builder</title> | |
| <!-- Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- React & ReactDOM --> | |
| <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> | |
| <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> | |
| <!-- Babel for JSX --> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| <style> | |
| /* Custom Scrollbar */ | |
| .custom-scrollbar::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb { | |
| background: #475569; | |
| border-radius: 4px; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { | |
| background: #64748b; | |
| } | |
| .tab-4 { | |
| tab-size: 4; | |
| } | |
| /* Animation utilities */ | |
| .transition-all { | |
| transition-property: all; | |
| transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); | |
| transition-duration: 150ms; | |
| } | |
| .footer-content { | |
| text-align: center; | |
| padding: 5px; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-slate-50 text-slate-900"> | |
| <div id="root"></div> | |
| <script type="text/babel"> | |
| const { useState, useEffect, useCallback } = React; | |
| // --- Icons (Replaced lucide-react with inline SVGs for standalone portability) --- | |
| const IconBase = ({ size = 24, className = "", children }) => ( | |
| <svg | |
| width={size} | |
| height={size} | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| className={className} | |
| > | |
| {children} | |
| </svg> | |
| ); | |
| const Plus = (props) => <IconBase {...props}><path d="M5 12h14M12 5v14"/></IconBase>; | |
| const Trash2 = (props) => <IconBase {...props}><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></IconBase>; | |
| const ChevronRight = (props) => <IconBase {...props}><path d="m9 18 6-6-6-6"/></IconBase>; | |
| const ChevronDown = (props) => <IconBase {...props}><path d="m6 9 6 6 6-6"/></IconBase>; | |
| const Copy = (props) => <IconBase {...props}><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></IconBase>; | |
| const Check = (props) => <IconBase {...props}><polyline points="20 6 9 17 4 12"/></IconBase>; | |
| const FileJson = (props) => <IconBase {...props}><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1"/></IconBase>; | |
| const Settings = (props) => <IconBase {...props}><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.1a2 2 0 0 1-1-1.72v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></IconBase>; | |
| const Type = (props) => <IconBase {...props}><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></IconBase>; | |
| const Box = (props) => <IconBase {...props}><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></IconBase>; | |
| const List = (props) => <IconBase {...props}><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></IconBase>; | |
| // --- UI Components --- | |
| const Button = ({ onClick, children, variant = 'primary', size = 'md', className = '', disabled = false, title = '' }) => { | |
| const baseStyles = "inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed"; | |
| const variants = { | |
| primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500", | |
| secondary: "bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 focus:ring-slate-400", | |
| ghost: "bg-transparent text-slate-600 hover:bg-slate-100 hover:text-slate-900 focus:ring-slate-400", | |
| danger: "bg-transparent text-red-500 hover:bg-red-50 hover:text-red-700 focus:ring-red-500", | |
| icon: "p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700", | |
| }; | |
| const sizes = { | |
| sm: "text-xs px-2.5 py-1.5", | |
| md: "text-sm px-4 py-2", | |
| icon: "p-1.5", | |
| }; | |
| return ( | |
| <button | |
| onClick={onClick} | |
| className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`} | |
| disabled={disabled} | |
| title={title} | |
| > | |
| {children} | |
| </button> | |
| ); | |
| }; | |
| const Input = ({ value, onChange, placeholder, className = '' }) => ( | |
| <input | |
| type="text" | |
| value={value} | |
| onChange={(e) => onChange(e.target.value)} | |
| placeholder={placeholder} | |
| className={`block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border px-3 py-1.5 ${className}`} | |
| /> | |
| ); | |
| const Select = ({ value, onChange, options, className = '' }) => ( | |
| <select | |
| value={value} | |
| onChange={(e) => onChange(e.target.value)} | |
| className={`block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border px-3 py-1.5 bg-white ${className}`} | |
| > | |
| {options.map(opt => ( | |
| <option key={opt.value} value={opt.value}>{opt.label}</option> | |
| ))} | |
| </select> | |
| ); | |
| // --- Schema Logic --- | |
| const SchemaNode = ({ | |
| id, | |
| data, | |
| path, | |
| onUpdate, | |
| onDelete, | |
| onAddChild, | |
| isRoot = false, | |
| isArrayItem = false | |
| }) => { | |
| const [isExpanded, setIsExpanded] = useState(true); | |
| const node = data[id]; | |
| if (!node) return null; | |
| const handleFieldChange = (field, value) => { | |
| onUpdate(id, { [field]: value }); | |
| }; | |
| const handleTypeChange = (newType) => { | |
| onUpdate(id, { type: newType }); | |
| }; | |
| const getTypeIcon = () => { | |
| if (node.type === 'object') return <Box size={14} className="text-blue-500" />; | |
| if (node.type === 'array') return <List size={14} className="text-purple-500" />; | |
| return <Type size={14} className="text-slate-400" />; | |
| }; | |
| return ( | |
| <div className={`relative ${!isRoot ? 'pl-6' : ''}`}> | |
| {/* Vertical Guide Line */} | |
| {!isRoot && ( | |
| <div className="absolute left-[11px] top-0 bottom-0 w-px bg-slate-200"></div> | |
| )} | |
| {/* Horizontal Connector */} | |
| {!isRoot && ( | |
| <div className="absolute left-[11px] top-5 w-3 h-px bg-slate-200"></div> | |
| )} | |
| <div className={`group relative flex flex-col gap-2 rounded-lg border border-transparent p-2 transition-all hover:border-slate-200 hover:bg-slate-50 ${isRoot ? 'bg-white shadow-sm border-slate-200 pb-4' : ''}`}> | |
| {/* Controls Row */} | |
| <div className="flex items-center gap-2"> | |
| {(node.type === 'object' || node.type === 'array') && ( | |
| <button | |
| onClick={() => setIsExpanded(!isExpanded)} | |
| className="text-slate-400 hover:text-slate-600 p-1 hover:bg-slate-200 rounded" | |
| > | |
| {isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />} | |
| </button> | |
| )} | |
| {/* Spacer for leaf nodes */} | |
| {!(node.type === 'object' || node.type === 'array') && <div className="w-6" />} | |
| {/* Field Name Input */} | |
| {!isRoot && !isArrayItem && ( | |
| <div className="flex-1 min-w-[120px] flex items-center gap-2"> | |
| {getTypeIcon()} | |
| <Input | |
| value={node.name} | |
| onChange={(val) => handleFieldChange('name', val)} | |
| placeholder="field_name" | |
| className="font-medium text-slate-900 font-mono" | |
| /> | |
| </div> | |
| )} | |
| {/* Labels */} | |
| {isRoot && ( | |
| <div className="flex items-center gap-2 px-2"> | |
| <Box size={16} className="text-blue-600" /> | |
| <span className="font-bold text-slate-700">Root Object</span> | |
| </div> | |
| )} | |
| {isArrayItem && ( | |
| <div className="flex items-center gap-2 px-2 bg-slate-100 rounded py-1"> | |
| <List size={14} className="text-slate-500" /> | |
| <span className="text-xs font-bold text-slate-500 uppercase tracking-wide">Items</span> | |
| </div> | |
| )} | |
| {/* Type Selector */} | |
| <div className="w-32"> | |
| <Select | |
| value={node.type} | |
| onChange={handleTypeChange} | |
| options={[ | |
| { value: 'string', label: 'String' }, | |
| { value: 'number', label: 'Number' }, | |
| { value: 'boolean', label: 'Boolean' }, | |
| { value: 'object', label: 'Object' }, | |
| { value: 'array', label: 'Array' }, | |
| ]} | |
| className={`${ | |
| node.type === 'object' ? 'text-blue-600 font-medium' : | |
| node.type === 'array' ? 'text-purple-600 font-medium' : | |
| 'text-slate-600' | |
| }`} | |
| /> | |
| </div> | |
| {/* Required Checkbox */} | |
| {!isRoot && !isArrayItem && ( | |
| <label className="flex items-center gap-1.5 cursor-pointer px-2 py-1.5 rounded hover:bg-slate-200/50 select-none" title="Mark as Required"> | |
| <input | |
| type="checkbox" | |
| checked={node.required} | |
| onChange={(e) => handleFieldChange('required', e.target.checked)} | |
| className="rounded border-slate-300 text-blue-600 focus:ring-blue-500" | |
| /> | |
| <span className="text-xs font-medium text-slate-600">Req</span> | |
| </label> | |
| )} | |
| {/* Action Buttons */} | |
| <div className="flex items-center ml-auto gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"> | |
| {node.type === 'object' && ( | |
| <Button variant="icon" size="icon" onClick={() => onAddChild(node.id)} title="Add Property"> | |
| <Plus size={16} /> | |
| </Button> | |
| )} | |
| {!isRoot && ( | |
| <Button variant="icon" size="icon" onClick={() => onDelete(node.id)} className="text-red-400 hover:text-red-600 hover:bg-red-50" title="Delete Field"> | |
| <Trash2 size={16} /> | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Description Input */} | |
| <div className="pl-10 pr-2"> | |
| <Input | |
| value={node.description || ''} | |
| onChange={(val) => handleFieldChange('description', val)} | |
| placeholder="Description (optional)..." | |
| className="text-xs text-slate-500 bg-transparent border-transparent focus:bg-white hover:bg-white focus:border-slate-300 hover:border-slate-200 placeholder:text-slate-400 px-0 py-0 h-6" | |
| /> | |
| </div> | |
| </div> | |
| {/* Children Rendering */} | |
| {isExpanded && ( | |
| <div className="mt-1"> | |
| {/* Object Children */} | |
| {node.type === 'object' && node.children && ( | |
| <div className="flex flex-col gap-1"> | |
| {node.children.length === 0 && ( | |
| <div className="pl-10 py-2 text-xs text-slate-400 italic flex items-center gap-2"> | |
| <div className="w-3 h-px bg-slate-200"></div> | |
| No properties. Click + to add. | |
| </div> | |
| )} | |
| {node.children.map((childId) => ( | |
| <SchemaNode | |
| key={childId} | |
| id={childId} | |
| data={data} | |
| path={[...path, 'children', childId]} | |
| onUpdate={onUpdate} | |
| onDelete={onDelete} | |
| onAddChild={onAddChild} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| {/* Array Items */} | |
| {node.type === 'array' && node.items && node.items.id && ( | |
| <div className="flex flex-col gap-1 mt-2"> | |
| <SchemaNode | |
| key={node.items.id} | |
| id={node.items.id} | |
| data={data} | |
| path={[...path, 'items']} | |
| onUpdate={onUpdate} | |
| onDelete={onDelete} | |
| onAddChild={onAddChild} | |
| isArrayItem={true} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| // --- App Component --- | |
| function App() { | |
| const [nodes, setNodes] = useState({ | |
| 'root': { | |
| id: 'root', | |
| type: 'object', | |
| name: 'root', | |
| description: 'The root object', | |
| required: false, | |
| children: ['field_1'] | |
| }, | |
| 'field_1': { | |
| id: 'field_1', | |
| name: 'id', | |
| type: 'string', | |
| description: 'Unique identifier', | |
| required: true, | |
| children: [] | |
| } | |
| }); | |
| const [generatedSchema, setGeneratedSchema] = useState(''); | |
| const [copySuccess, setCopySuccess] = useState(false); | |
| const updateNode = (id, updates) => { | |
| setNodes(prev => { | |
| const node = prev[id]; | |
| const newState = { ...prev }; | |
| if (updates.type) { | |
| const newType = updates.type; | |
| if (newType === 'object' && !node.children) { | |
| updates.children = []; | |
| } | |
| if (newType === 'array') { | |
| if (!node.items || !node.items.id) { | |
| const itemId = Math.random().toString(36).substr(2, 9); | |
| newState[itemId] = { | |
| id: itemId, | |
| type: 'string', | |
| description: 'Array item', | |
| required: false, | |
| name: '', | |
| }; | |
| updates.items = { id: itemId }; | |
| } | |
| } | |
| } | |
| newState[id] = { ...node, ...updates }; | |
| return newState; | |
| }); | |
| }; | |
| const addNode = (parentId) => { | |
| const newId = Math.random().toString(36).substr(2, 9); | |
| const newNode = { | |
| id: newId, | |
| name: 'new_field', | |
| type: 'string', | |
| description: '', | |
| required: false, | |
| children: [] | |
| }; | |
| setNodes(prev => ({ | |
| ...prev, | |
| [newId]: newNode, | |
| [parentId]: { | |
| ...prev[parentId], | |
| children: [...(prev[parentId].children || []), newId] | |
| } | |
| })); | |
| }; | |
| const deleteNode = (id) => { | |
| setNodes(prev => { | |
| const next = { ...prev }; | |
| const deleteRecursive = (nodeId) => { | |
| const node = next[nodeId]; | |
| if (!node) return; | |
| if (node.children) { | |
| node.children.forEach(childId => deleteRecursive(childId)); | |
| } | |
| if (node.items && node.items.id) { | |
| deleteRecursive(node.items.id); | |
| } | |
| delete next[nodeId]; | |
| }; | |
| Object.keys(next).forEach(key => { | |
| const parent = next[key]; | |
| if (parent.children && parent.children.includes(id)) { | |
| next[key] = { | |
| ...parent, | |
| children: parent.children.filter(childId => childId !== id) | |
| }; | |
| } | |
| }); | |
| deleteRecursive(id); | |
| return next; | |
| }); | |
| }; | |
| const generateOpenAPI = useCallback(() => { | |
| const buildNode = (nodeId) => { | |
| const node = nodes[nodeId]; | |
| if (!node) return {}; | |
| const schema = { | |
| type: node.type | |
| }; | |
| if (node.description) { | |
| schema.description = node.description; | |
| } | |
| if (node.type === 'object') { | |
| const properties = {}; | |
| const required = []; | |
| (node.children || []).forEach(childId => { | |
| const childNode = nodes[childId]; | |
| if (childNode) { | |
| properties[childNode.name] = buildNode(childId); | |
| if (childNode.required) { | |
| required.push(childNode.name); | |
| } | |
| } | |
| }); | |
| if (Object.keys(properties).length > 0) { | |
| schema.properties = properties; | |
| } | |
| if (required.length > 0) { | |
| schema.required = required; | |
| } | |
| } else if (node.type === 'array') { | |
| if (node.items && node.items.id) { | |
| schema.items = buildNode(node.items.id); | |
| } else { | |
| schema.items = { type: 'string' }; | |
| } | |
| } | |
| return schema; | |
| }; | |
| return buildNode('root'); | |
| }, [nodes]); | |
| useEffect(() => { | |
| const schema = generateOpenAPI(); | |
| setGeneratedSchema(JSON.stringify(schema, null, 2)); | |
| }, [nodes, generateOpenAPI]); | |
| const copyToClipboard = () => { | |
| // Fallback for clipboard in iframes/some browsers | |
| if (navigator.clipboard && navigator.clipboard.writeText) { | |
| navigator.clipboard.writeText(generatedSchema).then(() => { | |
| setCopySuccess(true); | |
| setTimeout(() => setCopySuccess(false), 2000); | |
| }); | |
| } else { | |
| const textArea = document.createElement("textarea"); | |
| textArea.value = generatedSchema; | |
| document.body.appendChild(textArea); | |
| textArea.select(); | |
| document.execCommand("copy"); | |
| document.body.removeChild(textArea); | |
| setCopySuccess(true); | |
| setTimeout(() => setCopySuccess(false), 2000); | |
| } | |
| }; | |
| return ( | |
| <div className="min-h-screen flex flex-col font-sans text-slate-900"> | |
| {/* Header */} | |
| <header className="bg-white border-b border-slate-200 sticky top-0 z-10 px-6 py-4 flex items-center justify-between shadow-sm"> | |
| <div className="flex items-center gap-3"> | |
| <div className="bg-blue-600 p-2 rounded-lg text-white"> | |
| <Settings size={20} /> | |
| </div> | |
| <div> | |
| <h1 className="text-xl font-bold text-slate-900">OpenAPI Schema Builder</h1> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <Button variant="secondary" size="sm" onClick={() => setNodes({'root': {id: 'root', type: 'object', name: 'root', children: []}})}> | |
| Clear All | |
| </Button> | |
| <Button variant="primary" size="sm" onClick={copyToClipboard}> | |
| {copySuccess ? <Check size={16} className="mr-2" /> : <Copy size={16} className="mr-2" />} | |
| {copySuccess ? 'Copied!' : 'Copy JSON'} | |
| </Button> | |
| </div> | |
| </header> | |
| {/* Main Content */} | |
| <main className="flex-1 flex flex-col lg:flex-row overflow-hidden h-[calc(100vh-73px)]"> | |
| {/* Left: Editor */} | |
| <div className="flex-1 overflow-y-auto p-6 lg:border-r border-slate-200 custom-scrollbar"> | |
| <div className="max-w-3xl mx-auto"> | |
| <div className="flex items-center justify-between mb-6"> | |
| <h2 className="text-lg font-semibold flex items-center gap-2"> | |
| <Type size={18} className="text-blue-600" /> | |
| Structure Editor | |
| </h2> | |
| <span className="text-xs text-slate-400 bg-slate-100 px-2 py-1 rounded">Auto-saving</span> | |
| </div> | |
| <div className="space-y-4"> | |
| <SchemaNode | |
| id="root" | |
| data={nodes} | |
| path={['root']} | |
| isRoot={true} | |
| onUpdate={updateNode} | |
| onDelete={deleteNode} | |
| onAddChild={addNode} | |
| /> | |
| </div> | |
| <div className="mt-12 text-center text-sm text-slate-400 pb-10"> | |
| <p>Add fields using the <span className="inline-flex align-middle bg-slate-200 rounded-full p-0.5 mx-1"><Plus size={12} /></span> button.</p> | |
| <p>To create a nested object, change a field's type to <span className="font-mono text-blue-600 font-bold">Object</span>.</p> | |
| <p>To create a list of items, change a field's type to <span className="font-mono text-purple-600 font-bold">Array</span>.</p> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Right: Preview */} | |
| <div className="w-full lg:w-[500px] bg-slate-900 flex flex-col border-l border-slate-800 shadow-2xl"> | |
| <div className="px-4 py-3 bg-slate-800 border-b border-slate-700 flex items-center justify-between"> | |
| <h2 className="text-slate-200 font-medium text-sm flex items-center gap-2"> | |
| <FileJson size={16} className="text-green-400" /> | |
| Live Preview | |
| </h2> | |
| <span className="text-[10px] font-mono text-slate-500 uppercase">OpenAPI 3.0 / JSON Schema</span> | |
| </div> | |
| <div className="flex-1 overflow-y-auto p-4 custom-scrollbar"> | |
| <pre className="font-mono text-xs leading-relaxed text-blue-300 tab-4"> | |
| <code dangerouslySetInnerHTML={{ | |
| __html: generatedSchema | |
| .replace(/"([^"]+)":/g, '<span class="text-purple-300">"$1"</span>:') // Keys | |
| .replace(/: "([^"]+)"/g, ': <span class="text-green-300">"$1"</span>') // String values | |
| .replace(/: (true|false)/g, ': <span class="text-orange-300">$1</span>') // Booleans | |
| .replace(/"(object|array)"/g, '<span class="text-yellow-300">"$1"</span>') // Types | |
| }} /> | |
| </pre> | |
| </div> | |
| <div className="p-3 bg-slate-800 border-t border-slate-700 text-xs text-slate-500 flex justify-between"> | |
| <span>{generatedSchema.length} chars</span> | |
| <span>Valid JSON</span> | |
| </div> | |
| </div> | |
| </main> | |
| <footer> | |
| <div className="font-mono text-xs footer-content bg-slate-800 text-slate-400"> | |
| <span>Created with Gemini 3.0 PRO</span> | |
| </div> | |
| </footer> | |
| </div> | |
| ); | |
| } | |
| // Render App | |
| const root = ReactDOM.createRoot(document.getElementById('root')); | |
| root.render(<App />); | |
| </script> | |
| </body> | |
| </html> |