260 lines
9.3 KiB
TypeScript
260 lines
9.3 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useCallback } from 'react';
|
|
import { Link2, Upload, LayoutGrid, FileText, X, Loader2 } from 'lucide-react';
|
|
|
|
type Tab = 'url' | 'file' | 'template';
|
|
|
|
interface SpecUploaderProps {
|
|
onAnalyze: (input: { type: 'url' | 'raw'; value: string }) => void;
|
|
loading?: boolean;
|
|
}
|
|
|
|
export function SpecUploader({ onAnalyze, loading = false }: SpecUploaderProps) {
|
|
const [activeTab, setActiveTab] = useState<Tab>('url');
|
|
const [urlInput, setUrlInput] = useState('');
|
|
const [dragActive, setDragActive] = useState(false);
|
|
const [fileName, setFileName] = useState<string | null>(null);
|
|
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
|
{ id: 'url', label: 'Paste URL', icon: <Link2 className="h-4 w-4" /> },
|
|
{ id: 'file', label: 'Upload File', icon: <Upload className="h-4 w-4" /> },
|
|
{ id: 'template', label: 'Pick Template', icon: <LayoutGrid className="h-4 w-4" /> },
|
|
];
|
|
|
|
const handleFileRead = useCallback((file: File) => {
|
|
const validExts = ['.json', '.yaml', '.yml'];
|
|
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
|
if (!validExts.includes(ext)) {
|
|
alert('Please upload a .json, .yaml, or .yml file');
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const content = e.target?.result as string;
|
|
setFileName(file.name);
|
|
setFileContent(content);
|
|
};
|
|
reader.readAsText(file);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback(
|
|
(e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDragActive(false);
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) handleFileRead(file);
|
|
},
|
|
[handleFileRead],
|
|
);
|
|
|
|
const handleSubmitUrl = () => {
|
|
if (!urlInput.trim()) return;
|
|
// Auto-detect: if it starts with http(s), it's a URL; otherwise raw spec content
|
|
const isUrl = /^https?:\/\//i.test(urlInput.trim());
|
|
onAnalyze({
|
|
type: isUrl ? 'url' : 'raw',
|
|
value: urlInput.trim(),
|
|
});
|
|
};
|
|
|
|
const handleSubmitFile = () => {
|
|
if (!fileContent) return;
|
|
onAnalyze({ type: 'raw', value: fileContent });
|
|
};
|
|
|
|
const clearFile = () => {
|
|
setFileName(null);
|
|
setFileContent(null);
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
};
|
|
|
|
return (
|
|
<div className="w-full">
|
|
{/* Tab header */}
|
|
<div className="flex border-b border-gray-800 mb-6">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`
|
|
flex items-center gap-2 px-5 py-3 text-sm font-medium
|
|
border-b-2 transition-all -mb-px
|
|
${
|
|
activeTab === tab.id
|
|
? 'border-indigo-500 text-indigo-400'
|
|
: 'border-transparent text-gray-500 hover:text-gray-300'
|
|
}
|
|
`}
|
|
>
|
|
{tab.icon}
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div className="min-h-[200px]">
|
|
{/* URL Tab */}
|
|
{activeTab === 'url' && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-2">
|
|
OpenAPI/Swagger spec URL or paste raw content
|
|
</label>
|
|
<textarea
|
|
value={urlInput}
|
|
onChange={(e) => setUrlInput(e.target.value)}
|
|
placeholder="https://api.example.com/openapi.json or paste spec content..."
|
|
rows={4}
|
|
className="w-full px-4 py-3 rounded-xl bg-gray-800 border border-gray-700
|
|
text-gray-100 placeholder-gray-500 text-sm
|
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent
|
|
resize-none transition-all"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleSubmitUrl}
|
|
disabled={!urlInput.trim() || loading}
|
|
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-lg
|
|
font-medium text-sm bg-indigo-600 text-white
|
|
hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed
|
|
transition-colors"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Analyzing...
|
|
</>
|
|
) : (
|
|
'Analyze'
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* File Tab */}
|
|
{activeTab === 'file' && (
|
|
<div className="space-y-4">
|
|
<div
|
|
onDragOver={(e) => { e.preventDefault(); setDragActive(true); }}
|
|
onDragLeave={() => setDragActive(false)}
|
|
onDrop={handleDrop}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className={`
|
|
flex flex-col items-center justify-center
|
|
border-2 border-dashed rounded-xl p-10 cursor-pointer
|
|
transition-all duration-200
|
|
${
|
|
dragActive
|
|
? 'border-indigo-500 bg-indigo-500/5'
|
|
: fileName
|
|
? 'border-emerald-500/50 bg-emerald-500/5'
|
|
: 'border-gray-700 hover:border-gray-500 bg-gray-800/30'
|
|
}
|
|
`}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".json,.yaml,.yml"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) handleFileRead(file);
|
|
}}
|
|
/>
|
|
{fileName ? (
|
|
<div className="flex items-center gap-3">
|
|
<FileText className="h-8 w-8 text-emerald-400" />
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-200">{fileName}</p>
|
|
<p className="text-xs text-gray-500">Click to replace or drag a new file</p>
|
|
</div>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); clearFile(); }}
|
|
className="p-1 rounded-full hover:bg-gray-700 text-gray-500 hover:text-gray-300"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Upload className="h-10 w-10 text-gray-600 mb-3" />
|
|
<p className="text-sm text-gray-400 mb-1">
|
|
Drop your API spec here or click to browse
|
|
</p>
|
|
<p className="text-xs text-gray-600">
|
|
Supports .json, .yaml, .yml
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
{fileName && (
|
|
<button
|
|
onClick={handleSubmitFile}
|
|
disabled={loading}
|
|
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-lg
|
|
font-medium text-sm bg-indigo-600 text-white
|
|
hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed
|
|
transition-colors"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Analyzing...
|
|
</>
|
|
) : (
|
|
'Analyze'
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Template Tab */}
|
|
{activeTab === 'template' && (
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-gray-400">
|
|
Start from a community template instead of an API spec.
|
|
</p>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
{[
|
|
{ name: 'Stripe Payments', category: 'Finance', tools: 24 },
|
|
{ name: 'HubSpot CRM', category: 'CRM', tools: 32 },
|
|
{ name: 'Shopify Store', category: 'eCommerce', tools: 28 },
|
|
{ name: 'GitHub Projects', category: 'DevTools', tools: 18 },
|
|
{ name: 'Slack Bot', category: 'Communication', tools: 15 },
|
|
{ name: 'Notion Workspace', category: 'ProjectMgmt', tools: 20 },
|
|
].map((tpl) => (
|
|
<a
|
|
key={tpl.name}
|
|
href="/marketplace"
|
|
className="p-3 rounded-lg bg-gray-800/50 border border-gray-800
|
|
hover:border-gray-600 transition-all group"
|
|
>
|
|
<p className="text-sm font-medium text-gray-200 group-hover:text-white">
|
|
{tpl.name}
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
{tpl.category} · {tpl.tools} tools
|
|
</p>
|
|
</a>
|
|
))}
|
|
</div>
|
|
<a
|
|
href="/marketplace"
|
|
className="inline-block text-sm text-indigo-400 hover:text-indigo-300 transition-colors"
|
|
>
|
|
Browse all templates →
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|