|
@@ -0,0 +1,332 @@
|
|
|
|
|
+import { ApiService } from '../../services/ApiService';
|
|
|
|
|
+import { ToastService } from '../../services/ToastService';
|
|
|
|
|
+import { getIcon } from '../../utils/icons';
|
|
|
|
|
+
|
|
|
|
|
+export class DocumentationPage {
|
|
|
|
|
+ private element: HTMLElement | null = null;
|
|
|
|
|
+ private endpoints: any[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ constructor(
|
|
|
|
|
+ private apiService: ApiService,
|
|
|
|
|
+ private toastService: ToastService
|
|
|
|
|
+ ) {}
|
|
|
|
|
+
|
|
|
|
|
+ render(): HTMLElement {
|
|
|
|
|
+ this.element = document.createElement('div');
|
|
|
|
|
+ this.element.className = 'space-y-6';
|
|
|
|
|
+
|
|
|
|
|
+ this.element.innerHTML = `
|
|
|
|
|
+ <!-- Header -->
|
|
|
|
|
+ <div class="md:flex md:items-center md:justify-between">
|
|
|
|
|
+ <div class="flex-1 min-w-0">
|
|
|
|
|
+ <h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-white sm:text-3xl sm:truncate">
|
|
|
|
|
+ API Documentation
|
|
|
|
|
+ </h2>
|
|
|
|
|
+ <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ Interactive documentation for all API endpoints
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="mt-4 flex md:mt-0 md:ml-4 space-x-3">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="btn btn-secondary"
|
|
|
|
|
+ id="refresh-endpoints"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="w-4 h-4 mr-2">
|
|
|
|
|
+ ${getIcon('refresh')}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ Refresh
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="btn btn-secondary"
|
|
|
|
|
+ id="view-openapi"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="w-4 h-4 mr-2">
|
|
|
|
|
+ ${getIcon('code')}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ OpenAPI Spec
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="btn btn-primary"
|
|
|
|
|
+ id="view-html-docs"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="w-4 h-4 mr-2">
|
|
|
|
|
+ ${getIcon('external-link')}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ View Full Docs
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Statistics Cards -->
|
|
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-6" id="stats-container">
|
|
|
|
|
+ <div class="card">
|
|
|
|
|
+ <div class="card-body text-center">
|
|
|
|
|
+ <div class="text-2xl font-bold text-primary-600 dark:text-primary-400" id="total-endpoints">-</div>
|
|
|
|
|
+ <div class="text-sm text-gray-600 dark:text-gray-400">Total Endpoints</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="card">
|
|
|
|
|
+ <div class="card-body text-center">
|
|
|
|
|
+ <div class="text-2xl font-bold text-green-600 dark:text-green-400" id="public-endpoints">-</div>
|
|
|
|
|
+ <div class="text-sm text-gray-600 dark:text-gray-400">Public Endpoints</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="card">
|
|
|
|
|
+ <div class="card-body text-center">
|
|
|
|
|
+ <div class="text-2xl font-bold text-orange-600 dark:text-orange-400" id="auth-endpoints">-</div>
|
|
|
|
|
+ <div class="text-sm text-gray-600 dark:text-gray-400">Auth Required</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- OpenAPI Modal -->
|
|
|
|
|
+ <div class="fixed inset-0 z-50 hidden" id="openapi-modal">
|
|
|
|
|
+ <div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
|
|
|
+ <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
|
|
|
|
+ <div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
|
|
|
|
|
+ <div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6">
|
|
|
|
|
+ <div class="sm:flex sm:items-start">
|
|
|
|
|
+ <div class="w-full">
|
|
|
|
|
+ <h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-4">
|
|
|
|
|
+ OpenAPI 3.0 Specification
|
|
|
|
|
+ </h3>
|
|
|
|
|
+ <div class="flex justify-between items-center mb-4">
|
|
|
|
|
+ <div class="text-sm text-gray-600 dark:text-gray-400">
|
|
|
|
|
+ Copy the JSON specification below
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="btn btn-secondary btn-sm"
|
|
|
|
|
+ id="copy-openapi"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="w-4 h-4 mr-1">
|
|
|
|
|
+ ${getIcon('copy')}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ Copy
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <pre class="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg overflow-auto max-h-96 text-xs">
|
|
|
|
|
+ <code id="openapi-content">Loading...</code>
|
|
|
|
|
+ </pre>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="btn btn-secondary sm:w-auto"
|
|
|
|
|
+ id="close-openapi-modal"
|
|
|
|
|
+ >
|
|
|
|
|
+ Close
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Endpoints List -->
|
|
|
|
|
+ <div class="card">
|
|
|
|
|
+ <div class="card-body">
|
|
|
|
|
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">API Endpoints</h3>
|
|
|
|
|
+ <div id="endpoints-container">
|
|
|
|
|
+ <div class="flex items-center justify-center py-12">
|
|
|
|
|
+ <div class="w-8 h-8 spinner"></div>
|
|
|
|
|
+ <span class="ml-3 text-gray-600 dark:text-gray-400">Loading endpoints...</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+
|
|
|
|
|
+ this.bindEvents();
|
|
|
|
|
+ this.loadEndpoints();
|
|
|
|
|
+
|
|
|
|
|
+ return this.element;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private bindEvents(): void {
|
|
|
|
|
+ if (!this.element) return;
|
|
|
|
|
+
|
|
|
|
|
+ const refreshBtn = this.element.querySelector('#refresh-endpoints');
|
|
|
|
|
+ const openApiBtn = this.element.querySelector('#view-openapi');
|
|
|
|
|
+ const htmlDocsBtn = this.element.querySelector('#view-html-docs');
|
|
|
|
|
+ const openApiModal = this.element.querySelector('#openapi-modal');
|
|
|
|
|
+ const closeModalBtn = this.element.querySelector('#close-openapi-modal');
|
|
|
|
|
+ const copyBtn = this.element.querySelector('#copy-openapi');
|
|
|
|
|
+
|
|
|
|
|
+ refreshBtn?.addEventListener('click', () => {
|
|
|
|
|
+ this.loadEndpoints();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ openApiBtn?.addEventListener('click', async () => {
|
|
|
|
|
+ await this.showOpenApiSpec();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ htmlDocsBtn?.addEventListener('click', () => {
|
|
|
|
|
+ window.open('/doc', '_blank');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ closeModalBtn?.addEventListener('click', () => {
|
|
|
|
|
+ openApiModal?.classList.add('hidden');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ copyBtn?.addEventListener('click', async () => {
|
|
|
|
|
+ const content = this.element?.querySelector('#openapi-content')?.textContent;
|
|
|
|
|
+ if (content) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await navigator.clipboard.writeText(content);
|
|
|
|
|
+ this.toastService.success('Copied!', 'OpenAPI specification copied to clipboard');
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ this.toastService.error('Copy Failed', 'Could not copy to clipboard');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Click outside to close modal
|
|
|
|
|
+ openApiModal?.addEventListener('click', (e) => {
|
|
|
|
|
+ if (e.target === openApiModal) {
|
|
|
|
|
+ openApiModal.classList.add('hidden');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async loadEndpoints(): Promise<void> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await this.apiService.getEndpointsList();
|
|
|
|
|
+
|
|
|
|
|
+ if (response.success && response.data) {
|
|
|
|
|
+ this.endpoints = response.data.endpoints;
|
|
|
|
|
+ this.updateStats();
|
|
|
|
|
+ this.renderEndpoints();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.toastService.error('Load Error', response.error || 'Failed to load endpoints');
|
|
|
|
|
+ this.renderError();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ this.toastService.error('Error', 'Failed to load endpoints');
|
|
|
|
|
+ this.renderError();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private updateStats(): void {
|
|
|
|
|
+ const totalEl = this.element?.querySelector('#total-endpoints');
|
|
|
|
|
+ const publicEl = this.element?.querySelector('#public-endpoints');
|
|
|
|
|
+ const authEl = this.element?.querySelector('#auth-endpoints');
|
|
|
|
|
+
|
|
|
|
|
+ if (totalEl && publicEl && authEl) {
|
|
|
|
|
+ const total = this.endpoints.length;
|
|
|
|
|
+ const publicEndpoints = this.endpoints.filter(e => !e.requiresAuth).length;
|
|
|
|
|
+ const authEndpoints = this.endpoints.filter(e => e.requiresAuth).length;
|
|
|
|
|
+
|
|
|
|
|
+ totalEl.textContent = total.toString();
|
|
|
|
|
+ publicEl.textContent = publicEndpoints.toString();
|
|
|
|
|
+ authEl.textContent = authEndpoints.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private renderEndpoints(): void {
|
|
|
|
|
+ const container = this.element?.querySelector('#endpoints-container');
|
|
|
|
|
+ if (!container) return;
|
|
|
|
|
+
|
|
|
|
|
+ // Group endpoints by tags
|
|
|
|
|
+ const grouped: { [key: string]: any[] } = {};
|
|
|
|
|
+ for (const endpoint of this.endpoints) {
|
|
|
|
|
+ const tag = endpoint.tags?.[0] || 'Other';
|
|
|
|
|
+ if (!grouped[tag]) {
|
|
|
|
|
+ grouped[tag] = [];
|
|
|
|
|
+ }
|
|
|
|
|
+ grouped[tag].push(endpoint);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const sections = Object.entries(grouped).map(([tag, endpoints]) => {
|
|
|
|
|
+ const endpointItems = endpoints.map(endpoint => `
|
|
|
|
|
+ <div class="border border-gray-200 dark:border-gray-600 rounded-lg p-4 mb-3">
|
|
|
|
|
+ <div class="flex items-center justify-between mb-2">
|
|
|
|
|
+ <div class="flex items-center space-x-3">
|
|
|
|
|
+ <span class="method-badge method-${endpoint.method.toLowerCase()}">${endpoint.method}</span>
|
|
|
|
|
+ <code class="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">${endpoint.path}</code>
|
|
|
|
|
+ ${endpoint.requiresAuth ? '<span class="badge badge-warning">🔒 Auth Required</span>' : '<span class="badge badge-success">🌐 Public</span>'}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p class="text-sm text-gray-600 dark:text-gray-400">${endpoint.summary}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('');
|
|
|
|
|
+
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="mb-6">
|
|
|
|
|
+ <h4 class="text-md font-semibold text-gray-900 dark:text-white mb-3 pb-2 border-b border-gray-200 dark:border-gray-600">
|
|
|
|
|
+ ${tag} (${endpoints.length})
|
|
|
|
|
+ </h4>
|
|
|
|
|
+ ${endpointItems}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }).join('');
|
|
|
|
|
+
|
|
|
|
|
+ container.innerHTML = `
|
|
|
|
|
+ <style>
|
|
|
|
|
+ .method-badge {
|
|
|
|
|
+ padding: 2px 8px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ min-width: 60px;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ }
|
|
|
|
|
+ .method-get { background-color: #61affe; }
|
|
|
|
|
+ .method-post { background-color: #49cc90; }
|
|
|
|
|
+ .method-put { background-color: #fca130; }
|
|
|
|
|
+ .method-patch { background-color: #50e3c2; }
|
|
|
|
|
+ .method-delete { background-color: #f93e3e; }
|
|
|
|
|
+ </style>
|
|
|
|
|
+ ${sections}
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private renderError(): void {
|
|
|
|
|
+ const container = this.element?.querySelector('#endpoints-container');
|
|
|
|
|
+ if (!container) return;
|
|
|
|
|
+
|
|
|
|
|
+ container.innerHTML = `
|
|
|
|
|
+ <div class="text-center py-12">
|
|
|
|
|
+ <div class="w-16 h-16 bg-red-100 dark:bg-red-900 rounded-lg flex items-center justify-center mx-auto mb-4">
|
|
|
|
|
+ <div class="w-8 h-8 text-red-600 dark:text-red-400">
|
|
|
|
|
+ ${getIcon('alert-circle')}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Failed to load endpoints</h3>
|
|
|
|
|
+ <p class="text-gray-500 dark:text-gray-400 mb-6">
|
|
|
|
|
+ Could not retrieve API documentation
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <button class="btn btn-primary" onclick="window.location.reload()">
|
|
|
|
|
+ Try Again
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async showOpenApiSpec(): Promise<void> {
|
|
|
|
|
+ const modal = this.element?.querySelector('#openapi-modal');
|
|
|
|
|
+ const content = this.element?.querySelector('#openapi-content');
|
|
|
|
|
+
|
|
|
|
|
+ if (!modal || !content) return;
|
|
|
|
|
+
|
|
|
|
|
+ modal.classList.remove('hidden');
|
|
|
|
|
+ content.textContent = 'Loading...';
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await this.apiService.getOpenApiSpec();
|
|
|
|
|
+
|
|
|
|
|
+ if (response.success && response.data) {
|
|
|
|
|
+ content.textContent = JSON.stringify(response.data, null, 2);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ content.textContent = `Error loading OpenAPI spec: ${response.error}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ content.textContent = 'Error loading OpenAPI specification';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|