diff --git a/.kilocode/mcp.json b/.kilocode/mcp.json new file mode 100644 index 0000000..7001130 --- /dev/null +++ b/.kilocode/mcp.json @@ -0,0 +1,3 @@ +{ + "mcpServers": {} +} \ No newline at end of file diff --git a/betterbase/apps/dashboard/src/app/(dashboard)/api-explorer/page.tsx b/betterbase/apps/dashboard/src/app/(dashboard)/api-explorer/page.tsx index ea5b3ac..405adfa 100644 --- a/betterbase/apps/dashboard/src/app/(dashboard)/api-explorer/page.tsx +++ b/betterbase/apps/dashboard/src/app/(dashboard)/api-explorer/page.tsx @@ -1,13 +1,727 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useMutation } from '@tanstack/react-query'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Code, + Play, + Copy, + Check, + Clock, + AlertCircle, + ChevronDown, + ChevronRight, + Trash2, + History, + Terminal, + FileCode, +} from 'lucide-react'; + +// Types +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +interface ApiRequest { + method: HttpMethod; + path: string; + body: string; + queryParams: { key: string; value: string }[]; +} + +interface ApiResponse { + status: number; + statusText: string; + data: unknown; + time: number; + headers: Record; +} + +interface HistoryItem extends ApiRequest { + id: string; + timestamp: number; + response?: { + status: number; + statusText: string; + data: unknown; + time: number; + }; +} + +// Available endpoints for the browser +const ENDPOINT_CATEGORIES = [ + { + name: 'Tables', + icon: 'Database', + endpoints: [ + { method: 'GET' as HttpMethod, path: '/rest/v1/users', description: 'List all users' }, + { method: 'GET' as HttpMethod, path: '/rest/v1/posts', description: 'List all posts' }, + { method: 'POST' as HttpMethod, path: '/rest/v1/users', description: 'Create a user' }, + { method: 'POST' as HttpMethod, path: '/rest/v1/posts', description: 'Create a post' }, + ], + }, + { + name: 'Auth', + icon: 'Lock', + endpoints: [ + { method: 'POST' as HttpMethod, path: '/auth/v1/signup', description: 'Sign up a new user' }, + { method: 'POST' as HttpMethod, path: '/auth/v1/login', description: 'Login a user' }, + { method: 'GET' as HttpMethod, path: '/auth/v1/user', description: 'Get current user' }, + { method: 'POST' as HttpMethod, path: '/auth/v1/logout', description: 'Logout current user' }, + ], + }, + { + name: 'Functions', + icon: 'Code', + endpoints: [ + { method: 'POST' as HttpMethod, path: '/functions/v1/hello', description: 'Hello world function' }, + { method: 'POST' as HttpMethod, path: '/functions/v1/send-email', description: 'Send email function' }, + ], + }, + { + name: 'Storage', + icon: 'Folder', + endpoints: [ + { method: 'GET' as HttpMethod, path: '/storage/v1/buckets', description: 'List buckets' }, + { method: 'POST' as HttpMethod, path: '/storage/v1/buckets', description: 'Create a bucket' }, + ], + }, +]; + +const HTTP_METHOD_COLORS: Record = { + GET: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + POST: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + PUT: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', + PATCH: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400', + DELETE: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', +}; + +const HISTORY_KEY = 'betterbase-api-explorer-history'; +const MAX_HISTORY_ITEMS = 20; + +export default function ApiExplorerPage() { + // Request state + const [method, setMethod] = useState('GET'); + const [path, setPath] = useState('/rest/v1/users'); + const [body, setBody] = useState('{\n \n}'); + const [queryParams, setQueryParams] = useState<{ key: string; value: string }[]>([]); + const [response, setResponse] = useState(null); + const [error, setError] = useState(null); + + // UI state + const [copied, setCopied] = useState(false); + const [expandedCategories, setExpandedCategories] = useState(['Tables']); + const [showHistory, setShowHistory] = useState(false); + const [history, setHistory] = useState([]); + const [activeTab, setActiveTab] = useState<'response' | 'curl' | 'js' | 'ts'>('response'); + + // Load history from localStorage + useEffect(() => { + try { + const stored = localStorage.getItem(HISTORY_KEY); + if (stored) { + setHistory(JSON.parse(stored) as HistoryItem[]); + } + } catch { + // Ignore parsing errors + } + }, []); + + // Save history to localStorage + const saveHistory = useCallback((newHistory: HistoryItem[]) => { + setHistory(newHistory); + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(newHistory)); + } catch { + // Ignore storage errors + } + }, []); + + // API call mutation + const { mutate: executeRequest, isPending } = useMutation({ + mutationFn: async () => { + const startTime = performance.now(); + const baseUrl = process.env.NEXT_PUBLIC_BETTERBASE_URL || 'http://localhost:3000'; + + // Build URL with query params + const url = new URL(`${baseUrl}${path}`); + queryParams.forEach(({ key, value }) => { + if (key.trim()) { + url.searchParams.append(key, value); + } + }); + + // Parse body for non-GET requests + let bodyData: string | undefined; + if (method !== 'GET' && body.trim()) { + bodyData = body; + } + + // Get token from localStorage + const token = typeof window !== 'undefined' ? localStorage.getItem('betterbase_token') : null; + + const response = await fetch(url.toString(), { + method, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: bodyData, + }); + + const endTime = performance.now(); + const responseData = await response.json().catch(() => null); + + // Extract headers + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + + return { + status: response.status, + statusText: response.statusText, + data: responseData, + time: Math.round(endTime - startTime), + headers, + }; + }, + onSuccess: (data) => { + setResponse(data); + setError(null); + + // Add to history using functional updater to avoid stale closure + const historyItem: HistoryItem = { + id: crypto.randomUUID(), + method, + path, + body, + queryParams, + timestamp: Date.now(), + response: { + status: data.status, + statusText: data.statusText, + data: data.data, + time: data.time, + }, + }; + + // Use functional updater to get latest history state + setHistory((currentHistory) => { + const newHistory = [historyItem, ...currentHistory].slice(0, MAX_HISTORY_ITEMS); + saveHistory(newHistory); + return newHistory; + }); + }, + onError: (err) => { + setError(err instanceof Error ? err.message : 'Request failed'); + setResponse(null); + }, + }); + + // Add query parameter + const addQueryParam = () => { + setQueryParams([...queryParams, { key: '', value: '' }]); + }; + + // Update query parameter + const updateQueryParam = (index: number, field: 'key' | 'value', value: string) => { + const updated = [...queryParams]; + updated[index][field] = value; + setQueryParams(updated); + }; + + // Remove query parameter + const removeQueryParam = (index: number) => { + setQueryParams(queryParams.filter((_, i) => i !== index)); + }; + + // Toggle category expansion + const toggleCategory = (name: string) => { + setExpandedCategories((prev) => + prev.includes(name) ? prev.filter((c) => c !== name) : [...prev, name] + ); + }; + + // Select endpoint from browser + const selectEndpoint = (endpointMethod: HttpMethod, endpointPath: string) => { + setMethod(endpointMethod); + setPath(endpointPath); + setBody(endpointMethod === 'GET' ? '{\n \n}' : '{\n "data": {}\n}'); + setQueryParams([]); + setResponse(null); + setError(null); + }; + + // Load from history + const loadFromHistory = (item: HistoryItem) => { + setMethod(item.method); + setPath(item.path); + setBody(item.body); + setQueryParams(item.queryParams); + if (item.response) { + setResponse({ + status: item.response.status, + statusText: item.response.statusText, + data: item.response.data, + time: item.response.time, + headers: {}, + }); + } + setError(null); + setShowHistory(false); + }; + + // Clear history + const clearHistory = () => { + saveHistory([]); + }; + + // Copy to clipboard + const copyToClipboard = async (text: string) => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + // Generate code snippets + const generateCurl = () => { + const baseUrl = process.env.NEXT_PUBLIC_BETTERBASE_URL || 'http://localhost:3000'; + const queryString = queryParams + .filter((p) => p.key.trim()) + .map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}`) + .join('&'); + const fullUrl = `${baseUrl}${path}${queryString ? `?${queryString}` : ''}`; + + let curl = `curl -X ${method} "${fullUrl}" \\ + -H "Content-Type: application/json"`; + + if (method !== 'GET' && body.trim()) { + const escapedBody = body.replace(/'/g, "'\\''"); + curl += ` \\ + -d '${escapedBody}'`; + } + + return curl; + }; + + const generateJs = () => { + const baseUrl = process.env.NEXT_PUBLIC_BETTERBASE_URL || 'http://localhost:3000'; + const queryString = queryParams + .filter((p) => p.key.trim()) + .map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}`) + .join('&'); + const fullUrl = `${baseUrl}${path}${queryString ? `?${queryString}` : ''}`; + + return `const response = await fetch("${fullUrl}", { + method: "${method}", + headers: { + "Content-Type": "application/json", + },${method !== 'GET' && body.trim() ? `\n body: JSON.stringify(${body}),` : ''} +}); + +const data = await response.json(); +console.log(data);`; + }; + + const generateTs = () => { + const baseUrl = process.env.NEXT_PUBLIC_BETTERBASE_URL || 'http://localhost:3000'; + const queryString = queryParams + .filter((p) => p.key.trim()) + .map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}`) + .join('&'); + const fullUrl = `${baseUrl}${path}${queryString ? `?${queryString}` : ''}`; + + const tableName = path.replace('/rest/v1/', ''); + + // Generate appropriate QueryBuilder method based on HTTP verb + let queryMethod: string; + switch (method) { + case 'GET': + queryMethod = `.from("${tableName}").select().execute()`; + break; + case 'POST': + queryMethod = `.from("${tableName}").insert(${body})`; + break; + case 'PUT': + case 'PATCH': + queryMethod = `.from("${tableName}").update("id", ${body})`; + break; + case 'DELETE': + queryMethod = `.from("${tableName}").delete("id")`; + break; + default: + queryMethod = ''; + } + + return `import { createClient } from '@betterbase/client'; + +const client = createClient({ + url: "${baseUrl}", +}); + +// Using the client +${path.startsWith('/rest/v1/') + ? `const { data, error } = await client + .from("${tableName}") + ${queryMethod}; + +if (error) { + console.error('Error:', error); +} else { + console.log('Data:', data); +}` + : `// Direct fetch for ${path} +const response = await fetch("${fullUrl}", { + method: "${method}", + headers: { + "Content-Type": "application/json", + },${method !== 'GET' && body.trim() ? `\n body: JSON.stringify(${body}),` : ''} +}); + +const data = await response.json();`}`; + }; + + const getCodeSnippet = () => { + switch (activeTab) { + case 'curl': + return generateCurl(); + case 'js': + return generateJs(); + case 'ts': + return generateTs(); + default: + return ''; + } + }; -export default function ApiPage() { return ( - - - API Explorer - Inspect generated endpoints and test requests. - - API explorer ships in Phase 9.3. - +
+
+
+

API Explorer

+

+ Explore and test your BetterBase API endpoints. +

+
+ +
+ +
+ {/* Endpoint Browser */} + + + Endpoints + Browse available API endpoints + + +
+ {ENDPOINT_CATEGORIES.map((category) => ( +
+ + {expandedCategories.includes(category.name) && ( +
+ {category.endpoints.map((endpoint) => ( + + ))} +
+ )} +
+ ))} +
+
+
+ + {/* Request Builder & Response */} +
+ {/* Request Builder */} + + + Request Builder + Build and execute API requests + + + {/* Method and Path */} +
+ + setPath(e.target.value)} + placeholder="/rest/v1/users" + className="flex-1 rounded-lg border border-zinc-200 bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary dark:border-zinc-800" + /> + +
+ + {/* Query Parameters */} +
+
+ + +
+ {queryParams.length > 0 && ( +
+ {queryParams.map((param, index) => ( +
+ updateQueryParam(index, 'key', e.target.value)} + placeholder="Key" + className="flex-1 rounded-lg border border-zinc-200 bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary dark:border-zinc-800" + /> + updateQueryParam(index, 'value', e.target.value)} + placeholder="Value" + className="flex-1 rounded-lg border border-zinc-200 bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary dark:border-zinc-800" + /> + +
+ ))} +
+ )} +
+ + {/* Request Body */} + {method !== 'GET' && ( +
+ +