UNPKG

freshroute-server

Version:

Local development server for FreshRoute extension with API mocking and extended functionality

1,002 lines (963 loc) 38.6 kB
"use strict"; class NetworkRecorder { constructor() { this.isRecording = false; this.capturedRequests = []; this.selectedRequests = /* @__PURE__ */ new Set(); this.recordingStartTime = null; this.recordingTimer = null; this.groups = []; this.initializeElements(); this.bindEvents(); this.loadGroups(); } initializeElements() { this.startBtn = document.getElementById("startRecording"); this.stopBtn = document.getElementById("stopRecording"); this.clearBtn = document.getElementById("clearRecording"); this.closeBtn = document.getElementById("closeWindow"); this.recorderStatus = document.getElementById("recorderStatus"); this.statusText = document.getElementById("statusText"); this.recordingDuration = document.getElementById("recordingDuration"); this.requestCount = document.getElementById("requestCount"); this.methodFilters = document.querySelectorAll('.method-filters input[type="checkbox"]'); this.statusFilters = document.querySelectorAll('.status-filters input[type="checkbox"]'); this.typeFilters = document.querySelectorAll('.type-filters input[type="checkbox"]'); this.urlFilter = document.getElementById("urlFilter"); this.requestsList = document.getElementById("requestsList"); this.emptyState = document.getElementById("emptyState"); this.selectAllBtn = document.getElementById("selectAll"); this.selectNoneBtn = document.getElementById("selectNone"); this.invertSelectionBtn = document.getElementById("invertSelection"); this.convertSection = document.getElementById("convertSection"); this.targetGroup = document.getElementById("targetGroup"); this.newGroupName = document.getElementById("newGroupName"); this.includeHeaders = document.getElementById("includeHeaders"); this.autoGenerateNames = document.getElementById("autoGenerateNames"); this.convertToMocksBtn = document.getElementById("convertToMocks"); this.selectedCount = document.getElementById("selectedCount"); this.requestModal = document.getElementById("requestModal"); this.modalBody = document.getElementById("modalBody"); this.closeModalBtn = document.getElementById("closeModal"); } bindEvents() { this.startBtn.addEventListener("click", () => this.startRecording()); this.stopBtn.addEventListener("click", () => this.stopRecording()); this.clearBtn.addEventListener("click", () => this.clearRecording()); this.closeBtn.addEventListener("click", () => { window.location.href = chrome.runtime.getURL("options.html"); }); const backBtn = document.getElementById("backToOptions"); if (backBtn) { backBtn.addEventListener("click", () => { window.location.href = chrome.runtime.getURL("options.html"); }); } this.selectAllBtn.addEventListener("click", () => this.selectAll()); this.selectNoneBtn.addEventListener("click", () => this.selectNone()); this.invertSelectionBtn.addEventListener("click", () => this.invertSelection()); this.methodFilters.forEach((filter) => { filter.addEventListener("change", () => this.applyFilters()); }); this.statusFilters.forEach((filter) => { filter.addEventListener("change", () => this.applyFilters()); }); this.typeFilters.forEach((filter) => { filter.addEventListener("change", () => this.applyFilters()); }); this.urlFilter.addEventListener("input", () => this.applyFilters()); this.targetGroup.addEventListener("change", () => { const isNewGroup = this.targetGroup.value === ""; this.newGroupName.style.display = isNewGroup ? "block" : "none"; }); this.convertToMocksBtn.addEventListener("click", () => this.convertToMocks()); this.closeModalBtn.addEventListener("click", () => this.closeModal()); this.requestModal.addEventListener("click", (e) => { if (e.target === this.requestModal) this.closeModal(); }); chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === "networkRequest" && this.isRecording) { this.addCapturedRequest(message.data); } else if (message.type === "networkResponse" && this.isRecording) { this.updateRequestWithResponse(message.data); } else if (message.type === "networkResponseBodyUpdate" && this.isRecording) { this.updateRequestWithResponseBody(message.data); } else if (message.type === "networkRequestUpdated" && this.isRecording) { console.log("📥 Network recorder received networkRequestUpdated message for:", message.request.url); this.updateRequestWithUpdatedData(message.request); } else if (message.type === "networkRequestCaptured" && this.isRecording) { this.addCapturedRequest(message.request); } else if (message.type === "debuggerDetached" && this.isRecording) { this.handleDebuggerDetached(message.reason); } else if (message.type === "recordingTabSwitched" && this.isRecording) { this.handleTabSwitched(message.tabId, message.url); } }); } async loadGroups() { try { const result = await chrome.storage.local.get(["groups"]); this.groups = result.groups || []; this.populateGroupSelect(); } catch (error) { console.error("Error loading groups:", error); } } populateGroupSelect() { while (this.targetGroup.children.length > 1) { this.targetGroup.removeChild(this.targetGroup.lastChild); } this.groups.forEach((group) => { const option = document.createElement("option"); option.value = group.id; option.textContent = group.name; this.targetGroup.appendChild(option); }); } startRecording() { this.isRecording = true; this.recordingStartTime = Date.now(); this.startBtn.style.display = "none"; this.stopBtn.style.display = "inline-flex"; this.clearBtn.style.display = "inline-flex"; this.recorderStatus.textContent = "Recording"; this.recorderStatus.className = "recorder-status recording"; this.statusText.textContent = "Recording network requests..."; this.recordingTimer = setInterval(() => { this.updateRecordingDuration(); }, 1e3); chrome.runtime.sendMessage({ type: "startNetworkRecording" }); this.updateRecordingDuration(); } stopRecording() { this.isRecording = false; this.startBtn.style.display = "inline-flex"; this.stopBtn.style.display = "none"; this.recorderStatus.textContent = "Stopped"; this.recorderStatus.className = "recorder-status stopped"; this.statusText.textContent = "Recording stopped"; if (this.recordingTimer) { clearInterval(this.recordingTimer); this.recordingTimer = null; } chrome.runtime.sendMessage({ type: "stopNetworkRecording" }); if (this.capturedRequests.length > 0) { this.convertSection.style.display = "block"; } } clearRecording() { this.capturedRequests = []; this.selectedRequests.clear(); this.renderRequestsList(); this.updateSelectedCount(); this.convertSection.style.display = "none"; if (!this.isRecording) { this.recorderStatus.textContent = "Ready"; this.recorderStatus.className = "recorder-status ready"; this.statusText.textContent = "Ready to record"; this.recordingDuration.textContent = "00:00"; this.clearBtn.style.display = "none"; } } updateRecordingDuration() { if (!this.recordingStartTime) return; const elapsed = Date.now() - this.recordingStartTime; const minutes = Math.floor(elapsed / 6e4); const seconds = Math.floor(elapsed % 6e4 / 1e3); this.recordingDuration.textContent = `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; } addCapturedRequest(requestData) { const request = { ...requestData, id: requestData.requestId || Date.now() + Math.random(), timestamp: Date.now(), status: null, // Will be updated when response arrives responseHeaders: null, statusLine: null }; this.capturedRequests.push(request); this.requestCount.textContent = this.capturedRequests.length; this.renderRequestsList(); } updateRequestWithResponse(responseData) { const request = this.capturedRequests.find((r) => r.requestId === responseData.requestId); if (request) { request.status = responseData.status; request.responseHeaders = responseData.responseHeaders; request.statusLine = responseData.statusLine; this.renderRequestsList(); } } updateRequestWithResponseBody(responseBodyData) { console.log("🔄 Network recorder received response body update for:", responseBodyData.url); const request = this.capturedRequests.find((r) => r.url === responseBodyData.url); if (request) { console.log("✅ Found matching request, updating response body"); request.responseBody = responseBodyData.responseBody; this.renderRequestsList(); } else { console.log("❌ No matching request found for URL:", responseBodyData.url); console.log("Available requests:", this.capturedRequests.map((r) => r.url)); } } updateRequestWithUpdatedData(updatedRequest) { const request = this.capturedRequests.find( (r) => r.url === updatedRequest.url && r.method === updatedRequest.method ); if (request) { console.log("✅ Found matching request, updating with CDP data:", updatedRequest.url); if (updatedRequest.status) request.status = updatedRequest.status; if (updatedRequest.statusLine) request.statusLine = updatedRequest.statusLine; if (updatedRequest.responseHeaders) request.responseHeaders = updatedRequest.responseHeaders; if (updatedRequest.requestHeaders) { request.requestHeaders = updatedRequest.requestHeaders; console.log("📋 Updated request headers, count:", Object.keys(updatedRequest.requestHeaders).length); } if (updatedRequest.requestBody) { request.requestBody = updatedRequest.requestBody; console.log("📤 Updated request body, type:", typeof updatedRequest.requestBody); } if (updatedRequest.responseBody) { request.responseBody = updatedRequest.responseBody; console.log("📥 Updated response body, length:", updatedRequest.responseBody.length); } this.renderRequestsList(); } else { console.log("❌ No matching request found for CDP update:", updatedRequest.url); console.log("Available requests:", this.capturedRequests.map((r) => `${r.method} ${r.url}`)); } } handleDebuggerDetached(reason) { console.log("🔌 Debugger detached:", reason); if (reason === "User cancelled debugging notification") { const notification = document.createElement("div"); notification.className = "notification warning"; notification.innerHTML = ` <span>⚠️ Debugger was detached - response bodies will not be captured</span> <button onclick="this.parentElement.remove()">×</button> `; document.body.appendChild(notification); setTimeout(() => { if (notification.parentElement) { notification.remove(); } }, 5e3); } } handleTabSwitched(tabId, url) { console.log("🔄 Recording switched to tab:", tabId, url); const notification = document.createElement("div"); notification.className = "notification info"; notification.innerHTML = ` <span>📍 Recording switched to: ${url}</span> <button onclick="this.parentElement.remove()">×</button> `; document.body.appendChild(notification); setTimeout(() => { if (notification.parentElement) { notification.remove(); } }, 3e3); } renderRequestsList() { const filteredRequests = this.getFilteredRequests(); if (filteredRequests.length === 0) { this.requestsList.innerHTML = ` <div class="empty-state"> <div class="empty-icon">📡</div> <div class="empty-title">${this.capturedRequests.length === 0 ? "No requests captured yet" : "No requests match filters"}</div> <div class="empty-subtitle">${this.capturedRequests.length === 0 ? 'Click "Start Recording" to begin capturing network requests' : "Try adjusting your filters"}</div> </div> `; return; } this.requestsList.innerHTML = filteredRequests.map( (request) => this.createRequestItemHTML(request) ).join(""); this.bindRequestItemEvents(); } createRequestItemHTML(request) { const isSelected = this.selectedRequests.has(request.id); const statusClass = request.status ? this.getStatusClass(request.status) : "status-pending"; const methodClass = `method-${request.method}`; const time = new Date(request.timestamp).toLocaleTimeString(); const statusText = request.status || "Pending"; let urlObj; try { urlObj = new URL(request.url); } catch { urlObj = { hostname: "unknown", pathname: request.url, search: "" }; } const displayUrl = request.url.length > 80 ? request.url.substring(0, 80) + "..." : request.url; const hasResponseData = request.responseBody || request.responseHeaders; const hasRequestData = request.requestBody || request.requestHeaders; return ` <div class="request-item ${isSelected ? "selected" : ""}" data-request-id="${request.id}" title="Double-click to view details"> <input type="checkbox" class="request-checkbox" ${isSelected ? "checked" : ""}> <div class="request-details"> <div class="request-first-line"> <span class="request-method ${methodClass}">${request.method}</span> <span class="request-url" title="${request.url}">${displayUrl}</span> <div class="request-indicators"> ${hasRequestData ? '<span class="data-indicator request-data" title="Has request data">📤</span>' : ""} ${hasResponseData ? '<span class="data-indicator response-data" title="Has response data">📥</span>' : ""} <span class="request-status ${statusClass}">${statusText}</span> </div> </div> <div class="request-second-line"> <div class="request-meta"> <span class="request-domain" title="Domain">${urlObj.hostname}</span> <span class="request-type type-${this.getTypeClass(request.type)}">${this.getTypeDisplayName(request.type)}</span> </div> <div class="request-actions"> <span class="request-time">${time}</span> <span class="view-details-hint">👁️ Double-click for details</span> </div> </div> </div> </div> `; } bindRequestItemEvents() { const requestItems = this.requestsList.querySelectorAll(".request-item"); requestItems.forEach((item) => { const checkbox = item.querySelector(".request-checkbox"); const requestId = item.dataset.requestId; checkbox.addEventListener("change", (e) => { e.stopPropagation(); this.toggleRequestSelection(requestId, checkbox.checked); }); item.addEventListener("click", (e) => { if (e.target === checkbox) return; const isSelected = this.selectedRequests.has(requestId); this.toggleRequestSelection(requestId, !isSelected); checkbox.checked = !isSelected; }); item.addEventListener("dblclick", () => { this.showRequestDetails(requestId); }); }); } toggleRequestSelection(requestId, selected) { if (selected) { this.selectedRequests.add(requestId); } else { this.selectedRequests.delete(requestId); } this.updateRequestItemSelection(requestId, selected); this.updateSelectedCount(); } updateRequestItemSelection(requestId, selected) { const item = this.requestsList.querySelector(`[data-request-id="${requestId}"]`); if (item) { item.classList.toggle("selected", selected); } } updateSelectedCount() { const count = this.selectedRequests.size; this.selectedCount.textContent = `${count} request${count !== 1 ? "s" : ""} selected`; } selectAll() { const filteredRequests = this.getFilteredRequests(); filteredRequests.forEach((request) => { this.selectedRequests.add(request.id); }); this.renderRequestsList(); this.updateSelectedCount(); } selectNone() { this.selectedRequests.clear(); this.renderRequestsList(); this.updateSelectedCount(); } invertSelection() { const filteredRequests = this.getFilteredRequests(); const newSelection = /* @__PURE__ */ new Set(); filteredRequests.forEach((request) => { if (!this.selectedRequests.has(request.id)) { newSelection.add(request.id); } }); this.selectedRequests = newSelection; this.renderRequestsList(); this.updateSelectedCount(); } getFilteredRequests() { return this.capturedRequests.filter((request) => { const methodChecked = Array.from(this.methodFilters).some((filter) => filter.checked && filter.value === request.method); if (!methodChecked) return false; const statusCategory = request.status ? this.getStatusCategory(request.status) : "2xx"; const statusChecked = Array.from(this.statusFilters).some((filter) => filter.checked && filter.value === statusCategory); if (!statusChecked) return false; const requestType = request.type || "other"; const typeChecked = Array.from(this.typeFilters).some((filter) => filter.checked && filter.value === requestType); if (!typeChecked) return false; const urlFilterValue = this.urlFilter.value.toLowerCase(); if (urlFilterValue && !request.url.toLowerCase().includes(urlFilterValue)) { return false; } return true; }); } getStatusCategory(status) { if (status >= 200 && status < 300) return "2xx"; if (status >= 300 && status < 400) return "3xx"; if (status >= 400 && status < 500) return "4xx"; if (status >= 500) return "5xx"; return "2xx"; } getStatusClass(status) { const category = this.getStatusCategory(status); return `status-${category}`; } getTypeClass(type) { const typeMap = { "xmlhttprequest": "xhr", "fetch": "fetch", "script": "script", "stylesheet": "css", "image": "image", "font": "font", "document": "document" }; return typeMap[type] || "other"; } getTypeDisplayName(type) { const typeMap = { "xmlhttprequest": "XHR", "fetch": "Fetch", "script": "JS", "stylesheet": "CSS", "image": "IMG", "font": "Font", "document": "Doc" }; return typeMap[type] || (type || "Other").toUpperCase(); } applyFilters() { this.renderRequestsList(); } showRequestDetails(requestId) { const request = this.capturedRequests.find((r) => r.id == requestId); if (!request) return; let urlObj; try { urlObj = new URL(request.url); } catch { urlObj = { hostname: "unknown", pathname: request.url, search: "" }; } const timestamp = request.timestamp ? new Date(request.timestamp).toLocaleString() : "Unknown"; const formatHeaders = (headers) => { if (!headers) return "No headers"; if (Array.isArray(headers)) { return headers.map((h) => `${h.name}: ${h.value}`).join("\n"); } if (typeof headers === "object") { return Object.entries(headers).map(([key, value]) => `${key}: ${value}`).join("\n"); } return JSON.stringify(headers, null, 2); }; const formatBody = (body) => { if (!body) return "No body"; if (typeof body === "string") { try { const parsed = JSON.parse(body); return JSON.stringify(parsed, null, 2); } catch { return body; } } if (body && typeof body === "object" && body.formData) { const formDataEntries = []; for (const [key, values] of Object.entries(body.formData)) { values.forEach((value) => { formDataEntries.push(`${key}=${value}`); }); } return formDataEntries.join("\\n"); } if (body && typeof body === "object" && body.raw) { const rawParts = body.raw.map((part) => { if (part.bytes) { try { return new TextDecoder().decode(new Uint8Array(part.bytes)); } catch { return "[Binary data]"; } } return part.file || "[File data]"; }); return rawParts.join("\\n"); } return JSON.stringify(body, null, 2); }; this.modalBody.innerHTML = ` <div class="request-detail-tabs"> <button class="detail-tab active" data-tab="general">General</button> <button class="detail-tab" data-tab="headers">Headers</button> <button class="detail-tab" data-tab="body">Request Body</button> <button class="detail-tab" data-tab="response">Response</button> </div> <div class="detail-tab-content active" id="general-tab"> <div class="request-detail-section"> <div class="detail-grid"> <div class="detail-item"> <span class="detail-label">URL:</span> <span class="detail-value url-value" title="${request.url}">${request.url}</span> </div> <div class="detail-item"> <span class="detail-label">Domain:</span> <span class="detail-value">${urlObj.hostname}</span> </div> <div class="detail-item"> <span class="detail-label">Path:</span> <span class="detail-value">${urlObj.pathname}${urlObj.search}</span> </div> <div class="detail-item"> <span class="detail-label">Method:</span> <span class="detail-value method-badge method-${request.method}">${request.method}</span> </div> <div class="detail-item"> <span class="detail-label">Status:</span> <span class="detail-value status-badge ${this.getStatusClass(request.status)}">${request.status || "Pending"}</span> </div> <div class="detail-item"> <span class="detail-label">Type:</span> <span class="detail-value type-badge type-${this.getTypeClass(request.type)}">${this.getTypeDisplayName(request.type)}</span> </div> <div class="detail-item"> <span class="detail-label">Timestamp:</span> <span class="detail-value">${timestamp}</span> </div> <div class="detail-item"> <span class="detail-label">Request ID:</span> <span class="detail-value">${request.requestId || "N/A"}</span> </div> </div> </div> </div> <div class="detail-tab-content" id="headers-tab"> <div class="request-detail-section"> <h4>Request Headers</h4> <div class="headers-container"> <button class="copy-btn" onclick="copyToClipboard(this)" data-copy-text="${formatHeaders(request.requestHeaders).replace(/"/g, "&quot;")}">Copy</button> <pre class="headers-content">${formatHeaders(request.requestHeaders)}</pre> </div> </div> <div class="request-detail-section"> <h4>Response Headers</h4> <div class="headers-container"> <button class="copy-btn" onclick="copyToClipboard(this)" data-copy-text="${formatHeaders(request.responseHeaders).replace(/"/g, "&quot;")}">Copy</button> <pre class="headers-content">${formatHeaders(request.responseHeaders)}</pre> </div> </div> </div> <div class="detail-tab-content" id="body-tab"> <div class="request-detail-section"> <h4>Request Body</h4> <div class="body-container"> <button class="copy-btn" onclick="copyToClipboard(this)" data-copy-text="${formatBody(request.requestBody).replace(/"/g, "&quot;")}">Copy</button> <pre class="body-content">${formatBody(request.requestBody)}</pre> </div> </div> </div> <div class="detail-tab-content" id="response-tab"> <div class="request-detail-section"> <h4>Response Body</h4> <div class="body-container"> <button class="copy-btn" onclick="copyToClipboard(this)" data-copy-text="${formatBody(request.responseBody).replace(/"/g, "&quot;")}">Copy</button> <pre class="body-content">${formatBody(request.responseBody)}</pre> </div> </div> ${request.statusLine ? ` <div class="request-detail-section"> <h4>Status Line</h4> <div class="status-line">${request.statusLine}</div> </div> ` : ""} </div> `; this.setupModalTabs(); this.requestModal.style.display = "flex"; } setupModalTabs() { const tabs = this.modalBody.querySelectorAll(".detail-tab"); const contents = this.modalBody.querySelectorAll(".detail-tab-content"); tabs.forEach((tab) => { tab.addEventListener("click", () => { tabs.forEach((t) => t.classList.remove("active")); contents.forEach((c) => c.classList.remove("active")); tab.classList.add("active"); const targetTab = tab.dataset.tab; const targetContent = this.modalBody.querySelector(`#${targetTab}-tab`); if (targetContent) { targetContent.classList.add("active"); } }); }); } closeModal() { this.requestModal.style.display = "none"; } async convertToMocks() { const selectedRequestIds = Array.from(this.selectedRequests); if (selectedRequestIds.length === 0) { alert("Please select at least one request to convert."); return; } const selectedRequests = this.capturedRequests.filter( (r) => selectedRequestIds.includes(r.id) ); let targetGroupId = this.targetGroup.value; let targetGroupName = ""; if (!targetGroupId) { targetGroupName = this.newGroupName.value.trim(); if (!targetGroupName) { const now = /* @__PURE__ */ new Date(); const dateStr = now.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); const domains = [...new Set(selectedRequests.map((req) => { try { return new URL(req.url).hostname; } catch { return "unknown"; } }))]; const domainInfo = domains.length === 1 ? domains[0] : `${domains.length} domains`; targetGroupName = `Network Recording ${dateStr} - ${domainInfo} (${selectedRequestIds.length} requests)`; } targetGroupId = "group_" + Date.now(); const newGroup = { id: targetGroupId, name: targetGroupName, rules: [] }; this.groups.push(newGroup); await chrome.storage.local.set({ groups: this.groups }); this.populateGroupSelect(); } else { const group = this.groups.find((g) => g.id === targetGroupId); targetGroupName = group ? group.name : "Unknown Group"; } const mockRules = selectedRequests.map((request) => this.convertRequestToMockRule(request)); try { for (const mockRule of mockRules) { await this.saveMockRuleToGroup(mockRule, targetGroupId); } alert(`Successfully converted ${mockRules.length} request(s) to mock rules in group "${targetGroupName}".`); this.selectNone(); } catch (error) { console.error("Error converting to mock rules:", error); alert("Error converting requests to mock rules. Please try again."); } } convertRequestToMockRule(request) { const autoGenerate = this.autoGenerateNames.checked; const includeHeaders = this.includeHeaders.checked; let ruleName = ""; if (autoGenerate) { const urlPath = new URL(request.url).pathname.split("/").pop() || "api"; ruleName = `${request.method} ${urlPath} (${request.status})`; } else { ruleName = `Mock Rule ${Date.now()}`; } const mockRule = { id: Date.now().toString(), type: "mock_response", name: ruleName, enabled: true, method: request.method, matchingType: "equals", // Use equals match for recorded URLs urlPattern: request.url, responseType: "json", // Default to JSON, can be changed later responseBody: request.responseBody || "", statusCode: request.status, delay: 0, description: `Auto-generated from network recording at ${(/* @__PURE__ */ new Date()).toLocaleString()}` }; if (includeHeaders && request.responseHeaders) { mockRule.responseHeaders = request.responseHeaders; } return mockRule; } async saveMockRuleToGroup(mockRule, groupId) { const result = await chrome.storage.local.get(["groups"]); const groups = result.groups || []; const groupIndex = groups.findIndex((g) => g.id === groupId); if (groupIndex === -1) { throw new Error("Group not found"); } groups[groupIndex].rules.push(mockRule); await chrome.storage.local.set({ groups }); this.groups = groups; } } document.addEventListener("DOMContentLoaded", () => { new NetworkRecorder(); }); const additionalCSS = ` /* Modal tab styles */ .request-detail-tabs { display: flex; border-bottom: 2px solid #e1e5e9; margin-bottom: 20px; gap: 0; } .detail-tab { background: none; border: none; padding: 12px 20px; cursor: pointer; font-weight: 500; color: #666; border-bottom: 3px solid transparent; transition: all 0.2s ease; font-size: 14px; } .detail-tab:hover { background: #f8f9fa; color: #333; } .detail-tab.active { color: #007bff; border-bottom-color: #007bff; background: #f8f9fa; } /* Tab content */ .detail-tab-content { display: none; animation: fadeIn 0.2s ease-in; } .detail-tab-content.active { display: block; } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } /* Request detail sections */ .request-detail-section { margin-bottom: 25px; background: #f8f9fa; border-radius: 8px; padding: 16px; border: 1px solid #e9ecef; } .request-detail-section h4 { margin: 0 0 15px 0; color: #495057; font-size: 16px; font-weight: 600; border-bottom: 2px solid #dee2e6; padding-bottom: 8px; display: flex; align-items: center; gap: 8px; } .request-detail-section h4::before { content: ''; width: 4px; height: 18px; background: #007bff; border-radius: 2px; } /* Detail grid */ .detail-grid { display: grid; gap: 12px; } .detail-item { display: grid; grid-template-columns: 140px 1fr; gap: 12px; align-items: start; padding: 8px 0; border-bottom: 1px solid #e9ecef; } .detail-item:last-child { border-bottom: none; } .detail-label { font-weight: 600; color: #495057; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; } .detail-value { font-family: 'Monaco', 'Menlo', 'Consolas', monospace; font-size: 13px; line-height: 1.4; color: #212529; background: #fff; padding: 6px 10px; border-radius: 4px; border: 1px solid #dee2e6; word-break: break-all; } .url-value { font-size: 12px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; transition: all 0.2s ease; } .url-value:hover { white-space: normal; word-break: break-all; background: #e3f2fd; border-color: #2196f3; } /* Status and method badges */ .method-badge, .status-badge, .type-badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; border: none; } .method-GET { background: #28a745; color: white; } .method-POST { background: #ffc107; color: #212529; } .method-PUT { background: #17a2b8; color: white; } .method-DELETE { background: #dc3545; color: white; } .method-PATCH { background: #6f42c1; color: white; } .method-OPTIONS { background: #6c757d; color: white; } .status-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .status-redirect { background: #fff3cd; color: #856404; border: 1px solid #ffeaa7; } .status-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .status-pending { background: #e2e3e5; color: #383d41; border: 1px solid #d6d8db; } /* Content containers */ .headers-container, .body-container { position: relative; } .headers-content, .body-content { background: #ffffff; border: 1px solid #dee2e6; border-radius: 6px; padding: 16px; font-family: 'Monaco', 'Menlo', 'Consolas', monospace; font-size: 12px; line-height: 1.5; max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; color: #212529; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1); } .headers-content:empty::before, .body-content:empty::before { content: 'No data available'; color: #6c757d; font-style: italic; font-family: -apple-system, BlinkMacSystemFont, sans-serif; } /* Copy buttons for content */ .copy-btn { position: absolute; top: 8px; right: 8px; background: #007bff; color: white; border: none; padding: 4px 8px; border-radius: 4px; font-size: 11px; cursor: pointer; opacity: 0; transition: opacity 0.2s ease; } .headers-container:hover .copy-btn, .body-container:hover .copy-btn { opacity: 1; } .copy-btn:hover { background: #0056b3; } .copy-btn.copied { background: #28a745; } /* Enhanced scrollbars */ .headers-content::-webkit-scrollbar, .body-content::-webkit-scrollbar { width: 8px; } .headers-content::-webkit-scrollbar-track, .body-content::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; } .headers-content::-webkit-scrollbar-thumb, .body-content::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 4px; } .headers-content::-webkit-scrollbar-thumb:hover, .body-content::-webkit-scrollbar-thumb:hover { background: #a8a8a8; } /* Status line styling */ .status-line { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Consolas', monospace; font-size: 13px; color: #495057; font-weight: 500; } /* JSON syntax highlighting */ .json-key { color: #d73a49; } .json-value { color: #032f62; } .json-string { color: #22863a; } .json-number { color: #e36209; } .json-boolean { color: #005cc5; } .json-null { color: #6f42c1; } `; const style = document.createElement("style"); style.textContent = additionalCSS; document.head.appendChild(style); window.copyToClipboard = function(button) { const text = button.getAttribute("data-copy-text"); if (!text) return; const cleanText = text.replace(/&quot;/g, '"').replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">"); navigator.clipboard.writeText(cleanText).then(() => { const originalText = button.textContent; button.textContent = "Copied!"; button.classList.add("copied"); setTimeout(() => { button.textContent = originalText; button.classList.remove("copied"); }, 2e3); }).catch((err) => { console.error("Failed to copy: ", err); const textArea = document.createElement("textarea"); textArea.value = cleanText; document.body.appendChild(textArea); textArea.select(); try { document.execCommand("copy"); button.textContent = "Copied!"; button.classList.add("copied"); setTimeout(() => { button.textContent = "Copy"; button.classList.remove("copied"); }, 2e3); } catch (err2) { console.error("Fallback copy failed: ", err2); } document.body.removeChild(textArea); }); };