1381 lines
63 KiB
Vue
1381 lines
63 KiB
Vue
<script setup>
|
|
import { useRoute } from 'vue-router'
|
|
|
|
definePageMeta({
|
|
title: "API Documentation",
|
|
layout: "empty",
|
|
});
|
|
|
|
// Import route to check for preview mode
|
|
const route = useRoute()
|
|
|
|
// API Documentation state
|
|
const apiSpec = ref(null)
|
|
const isLoading = ref(true)
|
|
const isError = ref(false)
|
|
const errorMessage = ref('')
|
|
const searchQuery = ref('')
|
|
const selectedEndpoint = ref(null)
|
|
const expandedTags = ref(new Set())
|
|
const activeTab = ref('overview')
|
|
const selectedLanguage = ref('javascript')
|
|
const copyNotification = ref(false)
|
|
|
|
// Try It Out functionality
|
|
const isTryingEndpoint = ref(false)
|
|
const endpointResponse = ref(null)
|
|
const endpointResponseStatus = ref(null)
|
|
const endpointResponseTime = ref(null)
|
|
const isEndpointLoading = ref(false)
|
|
const authToken = ref('')
|
|
const paramValues = ref({})
|
|
const requestBodyValue = ref('')
|
|
const showAuthModal = ref(false)
|
|
const selectedBaseUrl = ref('')
|
|
const customBaseUrl = ref('')
|
|
|
|
// Syntax highlighting for code examples
|
|
const getCodeClass = (language = '') => {
|
|
switch (language.toLowerCase()) {
|
|
case 'javascript': return 'language-javascript'
|
|
case 'python': return 'language-python'
|
|
case 'php': return 'language-php'
|
|
case 'ruby': return 'language-ruby'
|
|
case 'curl': return 'language-bash'
|
|
default: return 'language-javascript'
|
|
}
|
|
}
|
|
|
|
// Basic syntax highlighter
|
|
const highlightCode = (code, language) => {
|
|
if (!code) return ''
|
|
|
|
// Basic implementation to add highlighting
|
|
// First escape HTML entities to prevent XSS
|
|
const escapeHTML = (str) => {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
// Escape HTML first
|
|
let escapedCode = escapeHTML(code)
|
|
|
|
// Common patterns
|
|
const patterns = {
|
|
string: /(["'`])(?:(?=(\\?))\2.)*?\1/g,
|
|
comment: /\/\/.+|\/\*[\s\S]*?\*\//g,
|
|
keyword: /\b(const|let|var|function|return|if|else|for|while|try|catch|new|class|import|export|async|await|from)\b/g,
|
|
number: /\b\d+\.?\d*\b/g,
|
|
boolean: /\b(true|false|null|undefined)\b/g,
|
|
}
|
|
|
|
// Language-specific patterns
|
|
switch (language.toLowerCase()) {
|
|
case 'javascript':
|
|
// JavaScript syntax
|
|
return escapedCode
|
|
.replace(patterns.comment, '<span class="comment">$&</span>')
|
|
.replace(patterns.string, '<span class="string">$&</span>')
|
|
.replace(patterns.keyword, '<span class="keyword">$&</span>')
|
|
.replace(patterns.number, '<span class="number">$&</span>')
|
|
.replace(patterns.boolean, '<span class="boolean">$&</span>')
|
|
.replace(/\b(fetch|then|catch|console|log|error)\b/g, '<span class="function">$&</span>')
|
|
.replace(/(\(|\)|\{|\}|\[|\]|=>)/g, '<span class="bracket">$&</span>');
|
|
|
|
case 'python':
|
|
// Python syntax
|
|
return escapedCode
|
|
.replace(/#.+/g, '<span class="comment">$&</span>')
|
|
.replace(patterns.string, '<span class="string">$&</span>')
|
|
.replace(/\b(import|from|def|class|if|else|elif|for|while|try|except|return|and|or|not|in|is)\b/g, '<span class="keyword">$&</span>')
|
|
.replace(patterns.number, '<span class="number">$&</span>')
|
|
.replace(/\b(True|False|None)\b/g, '<span class="boolean">$&</span>')
|
|
.replace(/\b(print|requests|json|response)\b/g, '<span class="function">$&</span>')
|
|
.replace(/(\(|\)|\{|\}|\[|\]|:)/g, '<span class="bracket">$&</span>');
|
|
|
|
case 'php':
|
|
// PHP syntax
|
|
return escapedCode
|
|
.replace(/(<\?php|\?>)/g, '<span class="php-tag">$&</span>')
|
|
.replace(patterns.comment, '<span class="comment">$&</span>')
|
|
.replace(patterns.string, '<span class="string">$&</span>')
|
|
.replace(/\b(function|if|else|foreach|while|echo|return|try|catch|new|class|use|namespace)\b/g, '<span class="keyword">$&</span>')
|
|
.replace(patterns.number, '<span class="number">$&</span>')
|
|
.replace(/\b(true|false|null)\b/g, '<span class="boolean">$&</span>')
|
|
.replace(/\b(array|curl_init|curl_setopt|curl_exec|curl_close|json_encode)\b/g, '<span class="function">$&</span>')
|
|
.replace(/(\(|\)|\{|\}|\[|\]|;)/g, '<span class="bracket">$&</span>');
|
|
|
|
case 'ruby':
|
|
// Ruby syntax
|
|
return escapedCode
|
|
.replace(/#.+/g, '<span class="comment">$&</span>')
|
|
.replace(patterns.string, '<span class="string">$&</span>')
|
|
.replace(/\b(def|class|if|else|elsif|end|begin|rescue|require|module|attr_accessor|attr_reader)\b/g, '<span class="keyword">$&</span>')
|
|
.replace(patterns.number, '<span class="number">$&</span>')
|
|
.replace(/\b(true|false|nil)\b/g, '<span class="boolean">$&</span>')
|
|
.replace(/\b(puts|require|URI|Net::HTTP|JSON|to_json)\b/g, '<span class="function">$&</span>')
|
|
.replace(/(\(|\)|\{|\}|\[|\]|do\b|\|)/g, '<span class="bracket">$&</span>');
|
|
|
|
case 'curl':
|
|
case 'bash':
|
|
// cURL/bash syntax
|
|
return escapedCode
|
|
.replace(/#.+/g, '<span class="comment">$&</span>')
|
|
.replace(/'[^']*'|"[^"]*"/g, '<span class="string">$&</span>')
|
|
.replace(/\b(curl|echo|if|then|else|fi|for|while|do|done)\b/g, '<span class="keyword">$&</span>')
|
|
.replace(/(-X|-H|--header|-d|--data|-F|--form)\b/g, '<span class="param">$&</span>')
|
|
.replace(/\\$/gm, '<span class="escape">$&</span>');
|
|
|
|
default:
|
|
return escapedCode;
|
|
}
|
|
}
|
|
|
|
// Create HTML syntax highlighted code
|
|
const getHighlightedCode = computed(() => {
|
|
if (!selectedEndpoint.value) return ''
|
|
const code = generateCodeExample(selectedEndpoint.value)
|
|
return highlightCode(code, selectedLanguage.value)
|
|
})
|
|
|
|
// Base URLs from server definitions
|
|
const availableBaseUrls = computed(() => {
|
|
if (!apiSpec.value?.servers) return [{ url: window.location.origin, name: 'Current Origin' }]
|
|
|
|
const urls = apiSpec.value.servers.map((server, index) => {
|
|
// Process variables in the URL if any
|
|
let url = server.url
|
|
let name = server.description || `Server ${index + 1}`
|
|
|
|
// Replace variables with defaults
|
|
if (server.variables) {
|
|
Object.entries(server.variables).forEach(([varName, varConfig]) => {
|
|
const defaultValue = varConfig.default || ''
|
|
url = url.replace(`{${varName}}`, defaultValue)
|
|
})
|
|
}
|
|
|
|
return { url, name, originalDefinition: server }
|
|
})
|
|
|
|
// Add current origin as an option
|
|
urls.push({ url: window.location.origin, name: 'Current Origin' })
|
|
|
|
// Add custom URL option
|
|
urls.push({ url: 'custom', name: 'Custom URL...' })
|
|
|
|
return urls
|
|
})
|
|
|
|
// Current effective base URL
|
|
const effectiveBaseUrl = computed(() => {
|
|
if (selectedBaseUrl.value === 'custom') {
|
|
return customBaseUrl.value || window.location.origin
|
|
}
|
|
return selectedBaseUrl.value || (availableBaseUrls.value[0]?.url || window.location.origin)
|
|
})
|
|
|
|
// Check if this is preview mode
|
|
const isPreviewMode = computed(() => route.query.preview === 'true')
|
|
|
|
// Theme and configuration
|
|
const {
|
|
scalarConfig,
|
|
loadScalarConfig,
|
|
getScalarConfiguration
|
|
} = useScalarConfig()
|
|
|
|
// Available programming languages for code examples
|
|
const availableLanguages = [
|
|
{ value: 'javascript', label: 'JavaScript', icon: 'logos:javascript' },
|
|
{ value: 'python', label: 'Python', icon: 'logos:python' },
|
|
{ value: 'php', label: 'PHP', icon: 'logos:php' },
|
|
{ value: 'curl', label: 'cURL', icon: 'simple-icons:curl' },
|
|
{ value: 'ruby', label: 'Ruby', icon: 'logos:ruby' }
|
|
]
|
|
|
|
// Computed properties
|
|
const filteredEndpoints = computed(() => {
|
|
if (!apiSpec.value?.paths) return []
|
|
|
|
const endpoints = []
|
|
Object.entries(apiSpec.value.paths).forEach(([path, methods]) => {
|
|
Object.entries(methods).forEach(([method, details]) => {
|
|
if (typeof details === 'object' && details.operationId) {
|
|
endpoints.push({
|
|
path,
|
|
method: method.toUpperCase(),
|
|
summary: details.summary || '',
|
|
description: details.description || '',
|
|
tags: details.tags || [],
|
|
operationId: details.operationId,
|
|
parameters: details.parameters || [],
|
|
requestBody: details.requestBody,
|
|
responses: details.responses || {},
|
|
security: details.security || [],
|
|
details
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
if (!searchQuery.value) return endpoints
|
|
|
|
const query = searchQuery.value.toLowerCase()
|
|
return endpoints.filter(endpoint =>
|
|
endpoint.path.toLowerCase().includes(query) ||
|
|
endpoint.summary.toLowerCase().includes(query) ||
|
|
endpoint.description.toLowerCase().includes(query) ||
|
|
endpoint.tags.some(tag => tag.toLowerCase().includes(query))
|
|
)
|
|
})
|
|
|
|
const groupedEndpoints = computed(() => {
|
|
const groups = {}
|
|
filteredEndpoints.value.forEach(endpoint => {
|
|
const tag = endpoint.tags[0] || 'Other'
|
|
if (!groups[tag]) {
|
|
groups[tag] = []
|
|
}
|
|
groups[tag].push(endpoint)
|
|
})
|
|
return groups
|
|
})
|
|
|
|
const methodColors = {
|
|
'GET': 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-800',
|
|
'POST': 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800',
|
|
'PUT': 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800',
|
|
'DELETE': 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800',
|
|
'PATCH': 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800'
|
|
}
|
|
|
|
// Reset try-it-out form when endpoint changes
|
|
watch(selectedEndpoint, () => {
|
|
paramValues.value = {}
|
|
requestBodyValue.value = ''
|
|
endpointResponse.value = null
|
|
endpointResponseStatus.value = null
|
|
endpointResponseTime.value = null
|
|
isTryingEndpoint.value = false
|
|
|
|
// Initialize parameter values
|
|
if (selectedEndpoint.value?.parameters) {
|
|
selectedEndpoint.value.parameters.forEach(param => {
|
|
if (param.example !== undefined) {
|
|
paramValues.value[param.name] = param.example
|
|
} else if (param.schema?.default !== undefined) {
|
|
paramValues.value[param.name] = param.schema.default
|
|
} else {
|
|
paramValues.value[param.name] = ''
|
|
}
|
|
})
|
|
}
|
|
|
|
// Initialize request body if available
|
|
if (selectedEndpoint.value?.requestBody?.content) {
|
|
const contentType = Object.keys(selectedEndpoint.value.requestBody.content)[0]
|
|
const example = selectedEndpoint.value.requestBody.content[contentType]?.example
|
|
if (example) {
|
|
requestBodyValue.value = JSON.stringify(example, null, 2)
|
|
} else {
|
|
requestBodyValue.value = '{}'
|
|
}
|
|
}
|
|
})
|
|
|
|
// Generate code examples for different languages
|
|
const generateCodeExample = (endpoint, language = null) => {
|
|
const baseUrl = apiSpec.value?.servers?.[0]?.url || 'https://api.example.com'
|
|
const fullUrl = `${baseUrl}${endpoint.path}`
|
|
const lang = language || endpoint.__lang || selectedLanguage.value
|
|
|
|
const examples = {
|
|
javascript: `// Using fetch API
|
|
fetch('${fullUrl}', {
|
|
method: '${endpoint.method}',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer YOUR_TOKEN'
|
|
}${endpoint.requestBody ? ',\n body: JSON.stringify({\n // Your request data\n })' : ''}
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => console.log(data))
|
|
.catch(error => console.error('Error:', error));`,
|
|
|
|
python: `import requests
|
|
|
|
url = "${fullUrl}"
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"Authorization": "Bearer YOUR_TOKEN"
|
|
}
|
|
|
|
response = requests.${endpoint.method.toLowerCase()}(url, headers=headers${endpoint.requestBody ? ', json={}' : ''})
|
|
print(response.json())`,
|
|
|
|
php: `<?php
|
|
$url = "${fullUrl}";
|
|
$headers = array(
|
|
"Content-Type: application/json",
|
|
"Authorization: Bearer YOUR_TOKEN"
|
|
);
|
|
|
|
$ch = curl_init();
|
|
curl_setopt($ch, CURLOPT_URL, $url);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
${endpoint.method !== 'GET' ? `curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "${endpoint.method}");` : ''}
|
|
${endpoint.requestBody ? 'curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(array()));' : ''}
|
|
|
|
$response = curl_exec($ch);
|
|
curl_close($ch);
|
|
|
|
echo $response;
|
|
?>`,
|
|
|
|
curl: `curl -X ${endpoint.method} "${fullUrl}" \\
|
|
-H "Content-Type: application/json" \\
|
|
-H "Authorization: Bearer YOUR_TOKEN"${endpoint.requestBody ? ' \\\n -d \'{\n "key": "value"\n }\'' : ''}`,
|
|
|
|
ruby: `require 'net/http'
|
|
require 'json'
|
|
|
|
uri = URI('${fullUrl}')
|
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
http.use_ssl = true
|
|
|
|
request = Net::HTTP::${endpoint.method.charAt(0) + endpoint.method.slice(1).toLowerCase()}(uri)
|
|
request['Content-Type'] = 'application/json'
|
|
request['Authorization'] = 'Bearer YOUR_TOKEN'
|
|
${endpoint.requestBody ? 'request.body = {}.to_json' : ''}
|
|
|
|
response = http.request(request)
|
|
puts response.body`
|
|
}
|
|
|
|
return examples[lang] || examples.javascript
|
|
}
|
|
|
|
// Execute API request for Try It Out
|
|
const executeRequest = async () => {
|
|
if (!selectedEndpoint.value) return
|
|
|
|
isEndpointLoading.value = true
|
|
endpointResponse.value = null
|
|
endpointResponseStatus.value = null
|
|
|
|
try {
|
|
// Use the effective base URL
|
|
const baseUrl = effectiveBaseUrl.value
|
|
let url = `${baseUrl}${selectedEndpoint.value.path}`
|
|
|
|
// Replace path parameters
|
|
const pathParams = selectedEndpoint.value.parameters?.filter(p => p.in === 'path') || []
|
|
pathParams.forEach(param => {
|
|
if (paramValues.value[param.name]) {
|
|
url = url.replace(`{${param.name}}`, encodeURIComponent(paramValues.value[param.name]))
|
|
}
|
|
})
|
|
|
|
// Add query parameters
|
|
const queryParams = selectedEndpoint.value.parameters?.filter(p => p.in === 'query') || []
|
|
if (queryParams.length > 0) {
|
|
const queryString = queryParams
|
|
.filter(param => paramValues.value[param.name])
|
|
.map(param => `${param.name}=${encodeURIComponent(paramValues.value[param.name])}`)
|
|
.join('&')
|
|
|
|
if (queryString) {
|
|
url += url.includes('?') ? `&${queryString}` : `?${queryString}`
|
|
}
|
|
}
|
|
|
|
// Prepare headers
|
|
const headers = {
|
|
'Accept': 'application/json, text/plain, */*',
|
|
}
|
|
|
|
// Add authorization if provided
|
|
if (authToken.value) {
|
|
headers['Authorization'] = `Bearer ${authToken.value}`
|
|
}
|
|
|
|
// Add header parameters
|
|
const headerParams = selectedEndpoint.value.parameters?.filter(p => p.in === 'header') || []
|
|
headerParams.forEach(param => {
|
|
if (paramValues.value[param.name]) {
|
|
headers[param.name] = paramValues.value[param.name]
|
|
}
|
|
})
|
|
|
|
// Prepare request options
|
|
const options = {
|
|
method: selectedEndpoint.value.method,
|
|
headers,
|
|
credentials: 'include', // Include cookies for same-origin requests
|
|
}
|
|
|
|
// Add request body for non-GET requests if needed
|
|
if (selectedEndpoint.value.method !== 'GET' && selectedEndpoint.value.requestBody && requestBodyValue.value) {
|
|
try {
|
|
// Add content-type header if not present
|
|
if (!headers['Content-Type']) {
|
|
// Determine content type from the endpoint spec
|
|
const contentTypes = Object.keys(selectedEndpoint.value.requestBody.content || {})
|
|
if (contentTypes.includes('application/json')) {
|
|
headers['Content-Type'] = 'application/json'
|
|
// Parse and stringify to validate JSON
|
|
const parsedBody = JSON.parse(requestBodyValue.value)
|
|
options.body = JSON.stringify(parsedBody)
|
|
} else if (contentTypes.length > 0) {
|
|
// Use the first available content type
|
|
headers['Content-Type'] = contentTypes[0]
|
|
options.body = requestBodyValue.value
|
|
} else {
|
|
// Default to JSON if no content types specified
|
|
headers['Content-Type'] = 'application/json'
|
|
const parsedBody = JSON.parse(requestBodyValue.value)
|
|
options.body = JSON.stringify(parsedBody)
|
|
}
|
|
} else {
|
|
// Content-type already set from header params
|
|
options.body = requestBodyValue.value
|
|
}
|
|
} catch (e) {
|
|
throw new Error(`Invalid JSON format in request body: ${e.message}`)
|
|
}
|
|
}
|
|
|
|
console.log(`Making ${selectedEndpoint.value.method} request to ${url}`, options)
|
|
const startTime = Date.now()
|
|
|
|
// Make the request
|
|
try {
|
|
const response = await fetch(url, options)
|
|
const responseTime = Date.now() - startTime
|
|
endpointResponseTime.value = responseTime
|
|
|
|
// Store response status
|
|
endpointResponseStatus.value = response.status
|
|
|
|
// Parse response body
|
|
try {
|
|
const contentType = response.headers.get('content-type')
|
|
if (contentType && contentType.includes('application/json')) {
|
|
endpointResponse.value = await response.json()
|
|
} else {
|
|
endpointResponse.value = await response.text()
|
|
}
|
|
} catch (e) {
|
|
endpointResponse.value = await response.text() || 'No response body'
|
|
}
|
|
} catch (e) {
|
|
// Handle CORS and network errors
|
|
console.error('Network or CORS error:', e)
|
|
endpointResponseStatus.value = 0
|
|
endpointResponse.value = {
|
|
error: "Network or CORS error",
|
|
message: e.message,
|
|
note: "This may be due to CORS restrictions. Try using the API directly from your application instead."
|
|
}
|
|
}
|
|
|
|
// Show response tab automatically
|
|
activeTab.value = 'responses'
|
|
isTryingEndpoint.value = false
|
|
} catch (error) {
|
|
console.error('API request error:', error)
|
|
endpointResponseStatus.value = 0
|
|
endpointResponse.value = { error: error.message }
|
|
} finally {
|
|
isEndpointLoading.value = false
|
|
}
|
|
}
|
|
|
|
// Copy to clipboard functionality
|
|
const copyToClipboard = async (text) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
copyNotification.value = true
|
|
setTimeout(() => {
|
|
copyNotification.value = false
|
|
}, 2000)
|
|
} catch (err) {
|
|
console.error('Failed to copy text: ', err)
|
|
}
|
|
}
|
|
|
|
// Load API specification
|
|
const loadApiSpec = async () => {
|
|
isLoading.value = true
|
|
isError.value = false
|
|
|
|
try {
|
|
const response = await fetch('/api/openapi-file')
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch API specification: ${response.status}`)
|
|
}
|
|
|
|
apiSpec.value = await response.json()
|
|
console.log('API specification loaded:', apiSpec.value)
|
|
} catch (error) {
|
|
console.error('Failed to load API specification:', error)
|
|
isError.value = true
|
|
errorMessage.value = error.message
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
// Toggle tag expansion
|
|
const toggleTag = (tag) => {
|
|
if (expandedTags.value.has(tag)) {
|
|
expandedTags.value.delete(tag)
|
|
} else {
|
|
expandedTags.value.add(tag)
|
|
}
|
|
}
|
|
|
|
// Select endpoint
|
|
const selectEndpoint = (endpoint) => {
|
|
selectedEndpoint.value = endpoint
|
|
activeTab.value = 'overview'
|
|
}
|
|
|
|
// Initialize
|
|
onMounted(() => {
|
|
loadScalarConfig()
|
|
|
|
// Check if this is preview mode
|
|
if (isPreviewMode.value) {
|
|
try {
|
|
const previewConfig = localStorage.getItem('api-docs-preview-config')
|
|
if (previewConfig) {
|
|
const parsed = JSON.parse(previewConfig)
|
|
// Update the current config temporarily for preview
|
|
Object.assign(scalarConfig.value, parsed)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load preview config:', error)
|
|
}
|
|
}
|
|
|
|
loadApiSpec()
|
|
// Expand all tags by default
|
|
setTimeout(() => {
|
|
Object.keys(groupedEndpoints.value).forEach(tag => {
|
|
expandedTags.value.add(tag)
|
|
})
|
|
}, 100)
|
|
})
|
|
|
|
// After API spec is loaded, set default base URL
|
|
watch(apiSpec, (newSpec) => {
|
|
if (newSpec?.servers?.length) {
|
|
let baseUrl = newSpec.servers[0].url
|
|
|
|
// Replace variables with defaults
|
|
if (newSpec.servers[0].variables) {
|
|
Object.entries(newSpec.servers[0].variables).forEach(([varName, varConfig]) => {
|
|
const defaultValue = varConfig.default || ''
|
|
baseUrl = baseUrl.replace(`{${varName}}`, defaultValue)
|
|
})
|
|
}
|
|
|
|
selectedBaseUrl.value = baseUrl
|
|
} else {
|
|
selectedBaseUrl.value = window.location.origin
|
|
}
|
|
}, { immediate: true })
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen bg-slate-50 dark:bg-slate-900 font-inter">
|
|
<!-- Copy Notification -->
|
|
<Transition
|
|
enter-active-class="transition ease-out duration-300"
|
|
enter-from-class="transform opacity-0 scale-95"
|
|
enter-to-class="transform opacity-100 scale-100"
|
|
leave-active-class="transition ease-in duration-200"
|
|
leave-from-class="transform opacity-100 scale-100"
|
|
leave-to-class="transform opacity-0 scale-95"
|
|
>
|
|
<div
|
|
v-if="copyNotification"
|
|
class="fixed top-4 right-4 z-50 bg-emerald-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center space-x-2"
|
|
>
|
|
<Icon name="ic:outline-check" size="16" />
|
|
<span class="text-sm font-medium">Copied to clipboard!</span>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Authentication Modal -->
|
|
<Transition
|
|
enter-active-class="transition ease-out duration-300"
|
|
enter-from-class="transform opacity-0"
|
|
enter-to-class="transform opacity-100"
|
|
leave-active-class="transition ease-in duration-200"
|
|
leave-from-class="transform opacity-100"
|
|
leave-to-class="transform opacity-0"
|
|
>
|
|
<div v-if="showAuthModal" class="fixed inset-0 z-50">
|
|
<!-- Backdrop with click handler -->
|
|
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" @click="showAuthModal = false"></div>
|
|
|
|
<!-- Modal content -->
|
|
<div class="fixed inset-0 flex items-center justify-center pointer-events-none">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg max-w-md w-full p-6 m-4 pointer-events-auto">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Authentication</h3>
|
|
<button @click="showAuthModal = false" class="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
|
<Icon name="ic:outline-close" size="20" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Bearer Token</label>
|
|
<input
|
|
v-model="authToken"
|
|
type="text"
|
|
class="w-full border border-slate-300 dark:border-slate-600 rounded-lg px-3 py-2 bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
|
placeholder="Enter your auth token here"
|
|
/>
|
|
</div>
|
|
|
|
<div class="text-xs text-slate-500 dark:text-slate-400 mb-4">
|
|
<p>This token will be added to your requests as:</p>
|
|
<code class="block bg-slate-100 dark:bg-slate-700 p-2 rounded mt-1 font-mono">Authorization: Bearer {token}</code>
|
|
</div>
|
|
|
|
<button
|
|
@click="showAuthModal = false"
|
|
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
|
>
|
|
Save and Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Preview Mode Banner -->
|
|
<div
|
|
v-if="isPreviewMode"
|
|
class="bg-gradient-to-r from-amber-400 to-orange-500 text-white px-4 py-3 text-center"
|
|
>
|
|
<div class="flex items-center justify-center space-x-2">
|
|
<Icon name="ic:outline-visibility" size="18" />
|
|
<span class="text-sm font-medium">Preview Mode - This is how your API documentation will look with the current settings</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sticky Header -->
|
|
<header class="sticky top-0 z-40 bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm border-b border-slate-200 dark:border-slate-700">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex items-center justify-between h-16">
|
|
<!-- Logo and Title -->
|
|
<div class="flex items-center space-x-4">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center">
|
|
<Icon name="ic:outline-api" size="18" class="text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 class="text-lg font-semibold text-slate-900 dark:text-white">
|
|
{{ apiSpec?.info?.title || 'API Documentation' }}
|
|
</h1>
|
|
<div class="flex items-center space-x-2">
|
|
<span v-if="apiSpec?.info?.version" class="px-2 py-0.5 text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded">
|
|
v{{ apiSpec.info.version }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search and Controls -->
|
|
<div class="flex items-center space-x-4">
|
|
<!-- Search -->
|
|
<div class="relative">
|
|
<Icon name="ic:outline-search" size="16" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
placeholder="Search endpoints..."
|
|
class="pl-10 pr-4 py-2 w-80 border border-slate-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Language Selector -->
|
|
<select
|
|
v-model="selectedLanguage"
|
|
class="px-3 py-2 border border-slate-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
>
|
|
<option v-for="lang in availableLanguages" :key="lang.value" :value="lang.value">
|
|
{{ lang.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="isLoading" class="flex items-center justify-center h-96">
|
|
<div class="text-center">
|
|
<div class="w-12 h-12 mx-auto mb-4 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin"></div>
|
|
<p class="text-slate-600 dark:text-slate-400 font-medium">Loading API documentation...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="isError" class="max-w-2xl mx-auto mt-16 p-6">
|
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-8 text-center">
|
|
<div class="w-16 h-16 mx-auto mb-4 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
|
|
<Icon name="ic:outline-error" size="24" class="text-red-600 dark:text-red-400" />
|
|
</div>
|
|
<h3 class="text-lg font-semibold text-red-800 dark:text-red-200 mb-2">Failed to load API documentation</h3>
|
|
<p class="text-red-600 dark:text-red-400 mb-6">{{ errorMessage }}</p>
|
|
<button
|
|
@click="loadApiSpec"
|
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
|
|
>
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div v-else class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
|
<!-- Enhanced Sidebar -->
|
|
<div class="lg:col-span-1">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 sticky top-24">
|
|
<!-- API Info Section -->
|
|
<div class="p-6 border-b border-slate-200 dark:border-slate-700">
|
|
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">Overview</h2>
|
|
<div v-if="apiSpec?.info?.description" class="mb-4 p-4 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
|
|
<p class="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">{{ apiSpec.info.description }}</p>
|
|
</div>
|
|
|
|
<!-- Server Info -->
|
|
<div v-if="apiSpec?.servers?.length" class="space-y-2">
|
|
<h4 class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Base URL</h4>
|
|
<div v-for="server in apiSpec.servers" :key="server.url" class="font-mono text-sm text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 px-3 py-2 rounded-lg">
|
|
{{ server.url }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
<div class="p-6">
|
|
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-4">Endpoints</h2>
|
|
|
|
<!-- Endpoint Groups -->
|
|
<div class="space-y-2">
|
|
<div v-for="(endpoints, tag) in groupedEndpoints" :key="tag" class="border border-slate-200 dark:border-slate-600 rounded-lg overflow-hidden">
|
|
<button
|
|
@click="toggleTag(tag)"
|
|
class="w-full px-4 py-3 bg-slate-50 dark:bg-slate-700/50 hover:bg-slate-100 dark:hover:bg-slate-700 flex items-center justify-between text-left transition-colors group"
|
|
>
|
|
<span class="font-medium text-slate-900 dark:text-white capitalize text-sm">{{ tag }}</span>
|
|
<div class="flex items-center space-x-2">
|
|
<span class="text-xs text-slate-500 bg-slate-200 dark:bg-slate-600 px-2 py-1 rounded">{{ endpoints.length }}</span>
|
|
<Icon
|
|
:name="expandedTags.has(tag) ? 'ic:outline-expand-less' : 'ic:outline-expand-more'"
|
|
size="16"
|
|
class="text-slate-500 group-hover:text-slate-700 dark:group-hover:text-slate-300 transition-colors"
|
|
/>
|
|
</div>
|
|
</button>
|
|
|
|
<div v-show="expandedTags.has(tag)" class="divide-y divide-slate-200 dark:divide-slate-600">
|
|
<button
|
|
v-for="endpoint in endpoints"
|
|
:key="`${endpoint.method}-${endpoint.path}`"
|
|
@click="selectEndpoint(endpoint)"
|
|
class="w-full px-4 py-3 text-left hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors group"
|
|
:class="{ 'bg-blue-50 dark:bg-blue-900/20 border-r-2 border-blue-500': selectedEndpoint?.operationId === endpoint.operationId }"
|
|
>
|
|
<div class="flex items-center space-x-2 mb-2">
|
|
<span class="px-2 py-1 text-xs font-medium border rounded" :class="methodColors[endpoint.method]">
|
|
{{ endpoint.method }}
|
|
</span>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<code class="text-xs text-slate-600 dark:text-slate-400 block truncate">{{ endpoint.path }}</code>
|
|
<p class="text-sm text-slate-700 dark:text-slate-300 truncate">{{ endpoint.summary }}</p>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Enhanced Main Content -->
|
|
<div class="lg:col-span-3">
|
|
<div v-if="!selectedEndpoint" class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-12 text-center">
|
|
<div class="w-16 h-16 mx-auto mb-4 bg-slate-100 dark:bg-slate-700 rounded-full flex items-center justify-center">
|
|
<Icon name="ic:outline-touch-app" size="24" class="text-slate-400" />
|
|
</div>
|
|
<h3 class="text-xl font-semibold text-slate-900 dark:text-white mb-2">Select an endpoint</h3>
|
|
<p class="text-slate-600 dark:text-slate-400">Choose an endpoint from the sidebar to view its documentation and try it out</p>
|
|
</div>
|
|
|
|
<!-- Enhanced Endpoint Details -->
|
|
<div v-else class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
|
<!-- Endpoint Header -->
|
|
<div class="p-6 border-b border-slate-200 dark:border-slate-700 bg-gradient-to-r from-slate-50 to-white dark:from-slate-800 dark:to-slate-700">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div class="flex items-center space-x-3">
|
|
<span class="px-3 py-1 text-sm font-medium border rounded-lg" :class="methodColors[selectedEndpoint.method]">
|
|
{{ selectedEndpoint.method }}
|
|
</span>
|
|
<code class="text-xl font-mono text-slate-900 dark:text-white bg-slate-100 dark:bg-slate-600 px-3 py-1 rounded-lg">{{ selectedEndpoint.path }}</code>
|
|
</div>
|
|
<button
|
|
@click="copyToClipboard(generateCodeExample(selectedEndpoint))"
|
|
class="px-3 py-1 text-sm font-medium bg-blue-50 hover:bg-blue-100 text-blue-700 border border-blue-200 rounded-lg transition-colors flex items-center space-x-2"
|
|
>
|
|
<Icon name="ic:outline-content-copy" size="14" />
|
|
<span>Copy Code</span>
|
|
</button>
|
|
</div>
|
|
<h2 class="text-2xl font-bold text-slate-900 dark:text-white mb-2">{{ selectedEndpoint.summary }}</h2>
|
|
<p v-if="selectedEndpoint.description" class="text-slate-600 dark:text-slate-400 leading-relaxed">{{ selectedEndpoint.description }}</p>
|
|
</div>
|
|
|
|
<!-- Enhanced Tab Navigation -->
|
|
<div class="border-b border-slate-200 dark:border-slate-700">
|
|
<nav class="flex space-x-8 px-6" aria-label="Tabs">
|
|
<button
|
|
v-for="tab in [
|
|
{ id: 'overview', name: 'Overview', icon: 'ic:outline-info' },
|
|
{ id: 'parameters', name: 'Parameters', icon: 'ic:outline-settings' },
|
|
{ id: 'body', name: 'Request Body', icon: 'ic:outline-article' },
|
|
{ id: 'responses', name: 'Responses', icon: 'ic:outline-check-circle' },
|
|
{ id: 'examples', name: 'Code Examples', icon: 'ic:outline-code' }
|
|
]"
|
|
:key="tab.id"
|
|
@click="activeTab = tab.id"
|
|
class="flex items-center space-x-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
|
:class="activeTab === tab.id
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:text-slate-400 dark:hover:text-slate-300'"
|
|
>
|
|
<Icon :name="tab.icon" size="16" />
|
|
<span>{{ tab.name }}</span>
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Enhanced Content Sections -->
|
|
<div class="p-6">
|
|
<!-- Overview Tab -->
|
|
<div v-if="activeTab === 'overview'" class="space-y-6">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<!-- Quick Info -->
|
|
<div class="space-y-4">
|
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Quick Info</h3>
|
|
<div class="space-y-3">
|
|
<div class="flex items-center justify-between py-2 border-b border-slate-200 dark:border-slate-600">
|
|
<span class="text-sm font-medium text-slate-500 dark:text-slate-400">Operation ID</span>
|
|
<code class="text-sm text-slate-900 dark:text-white">{{ selectedEndpoint.operationId }}</code>
|
|
</div>
|
|
<div class="flex items-center justify-between py-2 border-b border-slate-200 dark:border-slate-600">
|
|
<span class="text-sm font-medium text-slate-500 dark:text-slate-400">Tags</span>
|
|
<div class="flex space-x-1">
|
|
<span
|
|
v-for="tag in selectedEndpoint.tags"
|
|
:key="tag"
|
|
class="px-2 py-1 text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded"
|
|
>
|
|
{{ tag }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div v-if="selectedEndpoint.security?.length" class="flex items-center justify-between py-2">
|
|
<span class="text-sm font-medium text-slate-500 dark:text-slate-400">Authentication</span>
|
|
<span class="px-2 py-1 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded">Required</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Try It Out -->
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Try It Out</h3>
|
|
<div class="flex items-center space-x-2">
|
|
<button
|
|
@click="showAuthModal = true"
|
|
class="text-sm flex items-center space-x-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
|
>
|
|
<Icon name="ic:outline-key" size="14" />
|
|
<span>{{ authToken ? 'Change Auth Token' : 'Add Auth Token' }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Base URL Selector -->
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Base URL</label>
|
|
<div class="flex space-x-2">
|
|
<select
|
|
v-model="selectedBaseUrl"
|
|
class="flex-1 border border-slate-300 dark:border-slate-600 rounded-lg px-3 py-2 bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
|
>
|
|
<option
|
|
v-for="baseUrl in availableBaseUrls"
|
|
:key="baseUrl.url"
|
|
:value="baseUrl.url"
|
|
>
|
|
{{ baseUrl.name }}: {{ baseUrl.url === 'custom' ? 'Custom URL' : baseUrl.url }}
|
|
</option>
|
|
</select>
|
|
<button
|
|
@click="activeTab = 'responses'"
|
|
v-if="endpointResponse"
|
|
class="px-3 py-2 bg-blue-50 hover:bg-blue-100 text-blue-700 border border-blue-200 rounded-lg transition-colors flex items-center space-x-1"
|
|
>
|
|
<Icon name="ic:outline-visibility" size="14" />
|
|
<span>View Response</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Custom URL input -->
|
|
<div v-if="selectedBaseUrl === 'custom'" class="mt-2">
|
|
<input
|
|
v-model="customBaseUrl"
|
|
type="text"
|
|
placeholder="Enter custom base URL (e.g. https://api.example.com)"
|
|
class="w-full border border-slate-300 dark:border-slate-600 rounded-lg px-3 py-2 bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
<span>Effective URL: <code class="font-mono">{{ effectiveBaseUrl + selectedEndpoint.path }}</code></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-4 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
|
<div v-if="!isTryingEndpoint">
|
|
<p class="text-sm text-blue-700 dark:text-blue-300 mb-3">
|
|
Test this endpoint directly from the documentation
|
|
<span v-if="selectedEndpoint.security?.length && !authToken" class="text-amber-600 dark:text-amber-400 block mt-1">
|
|
<Icon name="ic:outline-warning" size="14" class="inline-block mr-1" /> This endpoint requires authentication
|
|
</span>
|
|
</p>
|
|
<button
|
|
@click="isTryingEndpoint = true"
|
|
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center space-x-2"
|
|
>
|
|
<Icon name="ic:outline-play-arrow" size="16" />
|
|
<span>Configure Request</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Try It Out Form -->
|
|
<div v-else class="space-y-4">
|
|
<!-- Path Parameters -->
|
|
<div v-if="selectedEndpoint.parameters?.some(p => p.in === 'path')" class="space-y-3">
|
|
<h4 class="font-medium text-blue-800 dark:text-blue-300 text-sm">Path Parameters</h4>
|
|
<div
|
|
v-for="param in selectedEndpoint.parameters.filter(p => p.in === 'path')"
|
|
:key="param.name"
|
|
class="flex flex-col space-y-1"
|
|
>
|
|
<label class="text-xs font-medium text-slate-700 dark:text-slate-300">
|
|
{{ param.name }}
|
|
<span v-if="param.required" class="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
v-model="paramValues[param.name]"
|
|
type="text"
|
|
class="w-full px-3 py-1.5 text-sm border border-slate-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100"
|
|
:placeholder="param.description || param.name"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Query Parameters -->
|
|
<div v-if="selectedEndpoint.parameters?.some(p => p.in === 'query')" class="space-y-3">
|
|
<h4 class="font-medium text-blue-800 dark:text-blue-300 text-sm">Query Parameters</h4>
|
|
<div
|
|
v-for="param in selectedEndpoint.parameters.filter(p => p.in === 'query')"
|
|
:key="param.name"
|
|
class="flex flex-col space-y-1"
|
|
>
|
|
<label class="text-xs font-medium text-slate-700 dark:text-slate-300">
|
|
{{ param.name }}
|
|
<span v-if="param.required" class="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
v-model="paramValues[param.name]"
|
|
type="text"
|
|
class="w-full px-3 py-1.5 text-sm border border-slate-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100"
|
|
:placeholder="param.description || param.name"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Request Body -->
|
|
<div v-if="selectedEndpoint.method !== 'GET' && selectedEndpoint.requestBody" class="space-y-2">
|
|
<h4 class="font-medium text-blue-800 dark:text-blue-300 text-sm">Request Body</h4>
|
|
<textarea
|
|
v-model="requestBodyValue"
|
|
rows="5"
|
|
class="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 rounded font-mono bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100"
|
|
></textarea>
|
|
</div>
|
|
|
|
<!-- Request Actions -->
|
|
<div class="flex items-center space-x-2">
|
|
<button
|
|
@click="executeRequest"
|
|
:disabled="isEndpointLoading"
|
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 dark:disabled:bg-blue-900 text-white font-medium rounded-lg transition-colors flex items-center space-x-2 flex-1"
|
|
>
|
|
<span v-if="isEndpointLoading">
|
|
<span class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></span>
|
|
Sending...
|
|
</span>
|
|
<span v-else>
|
|
<Icon name="ic:outline-send" size="16" class="mr-2" />
|
|
Send Request
|
|
</span>
|
|
</button>
|
|
<button
|
|
@click="isTryingEndpoint = false"
|
|
class="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 dark:text-slate-200 font-medium rounded-lg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Parameters Tab -->
|
|
<div v-if="activeTab === 'parameters' && selectedEndpoint.parameters?.length" class="space-y-6">
|
|
<div class="overflow-hidden border border-slate-200 dark:border-slate-600 rounded-lg">
|
|
<div class="bg-slate-50 dark:bg-slate-700/50 px-6 py-3 border-b border-slate-200 dark:border-slate-600">
|
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Parameters</h3>
|
|
</div>
|
|
<div class="divide-y divide-slate-200 dark:divide-slate-600">
|
|
<div
|
|
v-for="param in selectedEndpoint.parameters"
|
|
:key="param.name"
|
|
class="p-6 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
|
|
>
|
|
<div class="flex items-start justify-between mb-3">
|
|
<div class="flex items-center space-x-3">
|
|
<code class="text-sm font-mono font-medium text-slate-900 dark:text-white">{{ param.name }}</code>
|
|
<span class="px-2 py-1 text-xs font-medium rounded"
|
|
:class="param.required
|
|
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
|
: 'bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-200'">
|
|
{{ param.required ? 'Required' : 'Optional' }}
|
|
</span>
|
|
<span class="px-2 py-1 text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded">
|
|
{{ param.in }}
|
|
</span>
|
|
</div>
|
|
<span class="text-sm text-slate-500 dark:text-slate-400 font-mono">
|
|
{{ param.schema?.type || param.type || 'string' }}
|
|
</span>
|
|
</div>
|
|
<p v-if="param.description" class="text-sm text-slate-600 dark:text-slate-400 leading-relaxed mb-3">
|
|
{{ param.description }}
|
|
</p>
|
|
<div v-if="param.schema?.enum" class="mt-3">
|
|
<span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide">Allowed Values:</span>
|
|
<div class="flex flex-wrap gap-1 mt-1">
|
|
<code
|
|
v-for="value in param.schema.enum"
|
|
:key="value"
|
|
class="px-2 py-1 text-xs bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded"
|
|
>
|
|
{{ value }}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Request Body Tab -->
|
|
<div v-if="activeTab === 'body' && selectedEndpoint.requestBody" class="space-y-6">
|
|
<div class="border border-slate-200 dark:border-slate-600 rounded-lg overflow-hidden">
|
|
<div class="bg-slate-50 dark:bg-slate-700/50 px-6 py-3 border-b border-slate-200 dark:border-slate-600">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Request Body</h3>
|
|
<span v-if="selectedEndpoint.requestBody.required" class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded">
|
|
Required
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="p-6">
|
|
<p v-if="selectedEndpoint.requestBody.description" class="text-sm text-slate-600 dark:text-slate-400 leading-relaxed mb-4">
|
|
{{ selectedEndpoint.requestBody.description }}
|
|
</p>
|
|
<div v-if="selectedEndpoint.requestBody.content" class="space-y-4">
|
|
<div v-for="(content, mediaType) in selectedEndpoint.requestBody.content" :key="mediaType">
|
|
<div class="flex items-center space-x-2 mb-3">
|
|
<span class="px-3 py-1 text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-lg">
|
|
{{ mediaType }}
|
|
</span>
|
|
</div>
|
|
<div v-if="content.example" class="relative">
|
|
<button
|
|
@click="copyToClipboard(JSON.stringify(content.example, null, 2))"
|
|
class="absolute top-3 right-3 p-2 bg-slate-800 hover:bg-slate-700 text-white rounded-lg transition-colors z-10"
|
|
>
|
|
<Icon name="ic:outline-content-copy" size="14" />
|
|
</button>
|
|
<pre class="bg-slate-900 text-slate-100 p-4 rounded-lg text-sm overflow-x-auto"><code>{{ JSON.stringify(content.example, null, 2) }}</code></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Responses Tab -->
|
|
<div v-if="activeTab === 'responses'" class="space-y-6">
|
|
<!-- Live Response (when available) -->
|
|
<div v-if="endpointResponse" class="border border-slate-200 dark:border-slate-600 rounded-lg overflow-hidden mb-6">
|
|
<div class="bg-slate-50 dark:bg-slate-700/50 px-6 py-3 border-b border-slate-200 dark:border-slate-600">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-white flex items-center">
|
|
<span class="inline-block w-2 h-2 rounded-full bg-emerald-500 mr-2 animate-pulse"></span>
|
|
Live Response
|
|
</h3>
|
|
<div class="flex items-center space-x-3">
|
|
<span
|
|
class="px-2 py-1 text-xs font-medium rounded"
|
|
:class="{
|
|
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300': endpointResponseStatus >= 200 && endpointResponseStatus < 300,
|
|
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300': endpointResponseStatus >= 300 && endpointResponseStatus < 500,
|
|
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300': endpointResponseStatus >= 500 || endpointResponseStatus === 0
|
|
}"
|
|
>
|
|
Status: {{ endpointResponseStatus || 'Error' }}
|
|
</span>
|
|
<span class="text-xs text-slate-500 dark:text-slate-400">{{ endpointResponseTime }}ms</span>
|
|
<button
|
|
@click="copyToClipboard(JSON.stringify(endpointResponse, null, 2))"
|
|
class="p-1 hover:bg-slate-200 dark:hover:bg-slate-600 rounded transition-colors"
|
|
>
|
|
<Icon name="ic:outline-content-copy" size="14" class="text-slate-500" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<pre class="bg-slate-900 text-slate-100 p-6 text-sm overflow-x-auto max-h-96"><code>{{ typeof endpointResponse === 'string' ? endpointResponse : JSON.stringify(endpointResponse, null, 2) }}</code></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Documentation Responses -->
|
|
<div v-if="Object.keys(selectedEndpoint.responses).length" class="space-y-4">
|
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-white mb-2">Documentation Responses</h3>
|
|
<div
|
|
v-for="(response, status) in selectedEndpoint.responses"
|
|
:key="status"
|
|
class="border border-slate-200 dark:border-slate-600 rounded-lg overflow-hidden"
|
|
>
|
|
<div class="bg-slate-50 dark:bg-slate-700/50 px-6 py-3 border-b border-slate-200 dark:border-slate-600">
|
|
<div class="flex items-center space-x-3">
|
|
<span class="px-3 py-1 text-sm font-medium rounded-lg"
|
|
:class="status.startsWith('2') ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300' :
|
|
status.startsWith('4') ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' :
|
|
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'">
|
|
{{ status }}
|
|
</span>
|
|
<span class="text-sm font-medium text-slate-900 dark:text-white">{{ response.description }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="p-6">
|
|
<div v-if="response.content" class="space-y-4">
|
|
<div v-for="(content, mediaType) in response.content" :key="mediaType">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="px-3 py-1 text-sm font-medium bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-200 rounded-lg">
|
|
{{ mediaType }}
|
|
</span>
|
|
</div>
|
|
<div v-if="content.example" class="relative">
|
|
<button
|
|
@click="copyToClipboard(JSON.stringify(content.example, null, 2))"
|
|
class="absolute top-3 right-3 p-2 bg-slate-800 hover:bg-slate-700 text-white rounded-lg transition-colors z-10"
|
|
>
|
|
<Icon name="ic:outline-content-copy" size="14" />
|
|
</button>
|
|
<pre class="bg-slate-900 text-slate-100 p-4 rounded-lg text-sm overflow-x-auto"><code>{{ JSON.stringify(content.example, null, 2) }}</code></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty state when no responses -->
|
|
<div v-if="!Object.keys(selectedEndpoint.responses).length && !endpointResponse" class="text-center py-12">
|
|
<div class="w-12 h-12 mx-auto mb-4 bg-slate-100 dark:bg-slate-700 rounded-full flex items-center justify-center">
|
|
<Icon name="ic:outline-info" size="20" class="text-slate-400" />
|
|
</div>
|
|
<h3 class="text-lg font-medium text-slate-900 dark:text-white mb-2">No responses defined</h3>
|
|
<p class="text-slate-600 dark:text-slate-400">This endpoint doesn't have any responses specified in the API specification.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Code Examples Tab -->
|
|
<div v-if="activeTab === 'examples'" class="space-y-6">
|
|
<div class="border border-slate-200 dark:border-slate-600 rounded-lg overflow-hidden">
|
|
<div class="bg-slate-50 dark:bg-slate-700/50 px-6 py-3 border-b border-slate-200 dark:border-slate-600">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Code Examples</h3>
|
|
<div class="flex items-center space-x-2">
|
|
<select
|
|
v-model="selectedLanguage"
|
|
class="px-3 py-1 text-sm border border-slate-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
>
|
|
<option v-for="lang in availableLanguages" :key="lang.value" :value="lang.value">
|
|
{{ lang.label }}
|
|
</option>
|
|
</select>
|
|
<button
|
|
@click="copyToClipboard(generateCodeExample(selectedEndpoint))"
|
|
class="px-3 py-1 text-sm font-medium bg-blue-50 hover:bg-blue-100 text-blue-700 border border-blue-200 rounded-lg transition-colors flex items-center space-x-1"
|
|
>
|
|
<Icon name="ic:outline-content-copy" size="14" />
|
|
<span>Copy</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="relative">
|
|
<pre class="bg-slate-900 p-6 text-sm overflow-x-auto"><code :class="getCodeClass(selectedLanguage)" class="syntax-highlighted" v-html="getHighlightedCode"></code></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Example usage instructions -->
|
|
<div class="border border-slate-200 dark:border-slate-600 rounded-lg overflow-hidden p-4">
|
|
<h4 class="text-sm font-semibold text-slate-900 dark:text-white mb-2">
|
|
{{ selectedLanguage === 'curl' ? 'Terminal Usage' : 'Code Usage' }}
|
|
</h4>
|
|
<div class="text-sm text-slate-600 dark:text-slate-400 space-y-2">
|
|
<p v-if="selectedLanguage === 'javascript'">
|
|
Copy this code into your JavaScript/Node.js application. You may need to install a fetch polyfill for Node.js.
|
|
</p>
|
|
<p v-else-if="selectedLanguage === 'python'">
|
|
Copy this code into your Python application. You'll need the <code class="px-1 py-0.5 bg-slate-100 dark:bg-slate-700 rounded font-mono text-xs">requests</code> library installed.
|
|
</p>
|
|
<p v-else-if="selectedLanguage === 'php'">
|
|
Copy this code into your PHP application. This uses the cURL extension which is usually enabled by default.
|
|
</p>
|
|
<p v-else-if="selectedLanguage === 'ruby'">
|
|
Copy this code into your Ruby application. This uses Ruby's built-in Net::HTTP library.
|
|
</p>
|
|
<p v-else-if="selectedLanguage === 'curl'">
|
|
Copy this command and run it in your terminal or command prompt.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty state for tabs with no content -->
|
|
<div v-if="(activeTab === 'parameters' && !selectedEndpoint.parameters?.length) ||
|
|
(activeTab === 'body' && !selectedEndpoint.requestBody)"
|
|
class="text-center py-12">
|
|
<div class="w-12 h-12 mx-auto mb-4 bg-slate-100 dark:bg-slate-700 rounded-full flex items-center justify-center">
|
|
<Icon name="ic:outline-info" size="20" class="text-slate-400" />
|
|
</div>
|
|
<h3 class="text-lg font-medium text-slate-900 dark:text-white mb-2">No {{ activeTab }} defined</h3>
|
|
<p class="text-slate-600 dark:text-slate-400">This endpoint doesn't have any {{ activeTab }} specified in the API specification.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Enhanced Typography */
|
|
.font-inter {
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
|
|
/* Syntax highlighting for code blocks */
|
|
.syntax-highlighted {
|
|
font-family: 'Fira Code', 'Consolas', monospace;
|
|
color: #f8f8f2; /* Default text color */
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* Common syntax highlighting classes */
|
|
.string { color: #f1fa8c; }
|
|
.comment { color: #6272a4; }
|
|
.keyword { color: #ff79c6; }
|
|
.number { color: #bd93f9; }
|
|
.boolean { color: #bd93f9; }
|
|
.function { color: #50fa7b; }
|
|
.bracket { color: #f8f8f2; }
|
|
.param { color: #ffb86c; }
|
|
.escape { color: #ff5555; }
|
|
.php-tag { color: #ff5555; }
|
|
|
|
/* Custom scrollbar for code blocks */
|
|
pre {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: rgba(148, 163, 184, 0.3) transparent;
|
|
}
|
|
|
|
pre::-webkit-scrollbar {
|
|
height: 8px;
|
|
width: 8px;
|
|
}
|
|
|
|
pre::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
pre::-webkit-scrollbar-thumb {
|
|
background-color: rgba(148, 163, 184, 0.3);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
pre::-webkit-scrollbar-thumb:hover {
|
|
background-color: rgba(148, 163, 184, 0.5);
|
|
}
|
|
|
|
/* Smooth transitions */
|
|
* {
|
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
transition-duration: 150ms;
|
|
}
|
|
|
|
/* Enhanced focus states */
|
|
button:focus,
|
|
input:focus,
|
|
select:focus {
|
|
outline: none;
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
}
|
|
|
|
/* Gradient effects for interactive elements */
|
|
.gradient-border {
|
|
background: linear-gradient(135deg, rgb(59, 130, 246), rgb(99, 102, 241));
|
|
}
|
|
</style> |