structured_output / index.html
philipp-zettl's picture
Update index.html
783e720 verified
<!DOCTYPE html>
<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>