1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = ["progress", "progressBar", "status", "qualityRating", "itemsList", "summary", "exports", "processingText"] 5 static values = { id: String, shortCode: String, sessionId: String } 6 7 connect() { 8 this.channel = this.application.consumer.subscriptions.create( 9 { 10 channel: 'ValidationReportChannel', 11 id: this.idValue, 12 session_id: this.sessionIdValue 13 }, 14 { 15 connected: () => { 16 console.log("Connected to ValidationReportChannel") 17 }, 18 disconnected: () => { 19 console.log("Disconnected from ValidationReportChannel") 20 }, 21 received: (data) => { 22 this.handleReceived(data) 23 } 24 } 25 ) 26 } 27 28 disconnect() { 29 if (this.channel) { 30 this.channel.unsubscribe() 31 } 32 } 33 34 handleReceived(data) { 35 console.log("ValidationReport received:", data.type, data) 36 switch (data.type) { 37 case 'status_update': 38 this.handleStatusUpdate(data) 39 break 40 case 'items_extracted': 41 this.handleItemsExtracted(data) 42 break 43 case 'item_update': 44 this.handleItemUpdate(data) 45 break 46 case 'completed': 47 this.handleCompleted(data) 48 break 49 case 'exports_ready': 50 this.handleExportsReady(data) 51 break 52 } 53 } 54 55 handleStatusUpdate(data) { 56 if (this.hasStatusTarget) { 57 this.statusTarget.textContent = this.humanizeStatus(data.status) 58 this.statusTarget.className = `badge bg-${this.statusBadgeClass(data.status)}` 59 } 60 this.updateProgress(data.summary) 61 } 62 63 handleItemsExtracted(data) { 64 console.log("handleItemsExtracted - hasItemsListTarget:", this.hasItemsListTarget, "items count:", data.items?.length) 65 if (!this.hasItemsListTarget) { 66 console.error("No itemsList target found!") 67 return 68 } 69 70 // Remove all data rows but keep the header (first li) 71 const rows = this.itemsListTarget.querySelectorAll('li.item-container[data-item-id]') 72 console.log("Removing", rows.length, "existing rows") 73 rows.forEach(row => row.remove()) 74 75 data.items.forEach((item, index) => { 76 console.log("Appending item", index + 1, item.id) 77 this.appendItemRow(item, index + 1) 78 }) 79 } 80 81 handleItemUpdate(data) { 82 console.log("handleItemUpdate - item:", data.item?.id, data.item?.status) 83 this.updateProgress(data.summary) 84 this.appendOrUpdateItem(data.item) 85 } 86 87 handleCompleted(data) { 88 if (this.hasStatusTarget) { 89 this.statusTarget.textContent = 'Completed' 90 this.statusTarget.className = 'badge fs-6 bg-success' 91 } 92 this.updateProgress(data.summary) 93 if (this.hasProgressTarget) { 94 this.progressTarget.classList.add('d-none') 95 } 96 // Hide processing text 97 if (this.hasProcessingTextTarget) { 98 this.processingTextTarget.classList.add('d-none') 99 } 100 // Show quality rating 101 if (data.summary.quality_rating_label) { 102 this.showQualityRating(data.summary) 103 } 104 } 105 106 showQualityRating(summary) { 107 // Find or create quality rating badge 108 if (this.hasQualityRatingTarget) { 109 this.qualityRatingTarget.innerHTML = `<i class="bi bi-star-fill me-1"></i>${summary.quality_rating_label}` 110 this.qualityRatingTarget.className = `badge fs-6 bg-${summary.quality_rating_class}` 111 this.qualityRatingTarget.classList.remove('d-none') 112 } else if (this.hasStatusTarget) { 113 // Create and insert after status badge 114 const qualityBadge = document.createElement('span') 115 qualityBadge.className = `badge fs-6 bg-${summary.quality_rating_class}` 116 qualityBadge.innerHTML = `<i class="bi bi-star-fill me-1"></i>${summary.quality_rating_label}` 117 qualityBadge.setAttribute('data-validation-report-target', 'qualityRating') 118 this.statusTarget.parentNode.insertBefore(qualityBadge, this.statusTarget.nextSibling) 119 } 120 } 121 122 handleExportsReady(data) { 123 if (this.hasExportsTarget) { 124 this.exportsTarget.innerHTML = ` 125 <a href="${data.csv_url}" class="btn btn-outline-primary"> 126 <i class="bi bi-file-earmark-csv me-1"></i> Download CSV 127 </a> 128 <a href="${data.pdf_url}" class="btn btn-primary"> 129 <i class="bi bi-file-earmark-pdf me-1"></i> Download PDF 130 </a> 131 <button class="btn btn-outline-secondary" type="button" data-action="click->
131validation-report#copyLink"> 132 <i class="bi bi-link-45deg me-1"></i> Copy Link 133 </button> 134 ` 135 this.exportsTarget.classList.remove('d-none') 136 } 137 } 138 139 copyLink(event) { 140 const url = `${window.location.origin}/validate/${this.shortCodeValue}` 141 const button = event.currentTarget 142 143 this.copyToClipboard(url).then(() => { 144 // Update button to show success 145 const originalHTML = button.innerHTML 146 button.innerHTML = '<i class="bi bi-check me-1"></i> Copied!' 147 button.classList.remove('btn-outline-secondary') 148 button.classList.add('btn-success') 149 150 setTimeout(() => { 151 button.innerHTML = originalHTML 152 button.classList.remove('btn-success') 153 button.classList.add('btn-outline-secondary') 154 }, 2000) 155 }) 156 } 157 158 async copyToClipboard(text) { 159 // Try modern clipboard API first 160 if (navigator.clipboard && navigator.clipboard.writeText) { 161 try { 162 await navigator.clipboard.writeText(text) 163 return 164 } catch (err) { 165 // Fall through to fallback 166 } 167 } 168 169 // Fallback for older browsers or non-HTTPS 170 const textArea = document.createElement('textarea') 171 textArea.value = text 172 textArea.style.position = 'fixed' 173 textArea.style.left = '-9999px' 174 document.body.appendChild(textArea) 175 textArea.select() 176 try { 177 document.execCommand('copy') 178 } catch (err) { 179 prompt('Copy this link:', text) 180 } 181 document.body.removeChild(textArea) 182 } 183 184 updateProgress(summary) { 185 if (this.hasProgressBarTarget && summary.total > 0) { 186 const percentage = summary.progress 187 this.progressBarTarget.style.width = `${percentage}%` 188 } 189 190 if (this.hasSummaryTarget) { 191 this.summaryTarget.innerHTML = ` 192 <div class="col-md-3 col-6 mb-3 mb-md-0"> 193 <div class="fs-3 fw-bold text-slate-700">${summary.completed}/${summary.total}</div> 194 <div class="small text-muted">Checked</div> 195 </div> 196 <div class="col-md-3 col-6 mb-3 mb-md-0"> 197 <div class="fs-3 fw-bold text-success">${summary.ok}</div> 198 <div class="small text-muted">OK (${summary.ok_pct}%)</div> 199 </div> 200 <div class="col-md-3 col-6"> 201 <div class="fs-3 fw-bold text-warning">${summary.warnings}</div> 202 <div class="small text-muted">Warnings (${summary.warnings_pct}%)</div> 203 </div> 204 <div class="col-md-3 col-6"> 205 <div class="fs-3 fw-bold text-danger">${summary.errors}</div> 206 <div class="small text-muted">Errors (${summary.errors_pct}%)</div> 207 </div> 208 ` 209 } 210 } 211 212 appendOrUpdateItem(item) { 213 console.log("appendOrUpdateItem - hasItemsListTarget:", this.hasItemsListTarget, "item:", item?.id) 214 if (!this.hasItemsListTarget) { 215 console.error("No itemsListTarget!") 216 return 217 } 218 219 const existingRow = this.itemsListTarget.querySelector(`[data-item-id="${item.id}"]`) 220 console.log("existingRow found:", !!existingRow) 221 222 if (existingRow) { 223 const rowNumber = existingRow.querySelector('.attribute').textContent 224 existingRow.innerHTML = this.buildRowHtml(item, rowNumber) 225 } else { 226 // Count items excluding the header row (first li) 227 const rowCount = this.itemsListTarget.querySelectorAll('li.item-container').length - 1 228 console.log("Appending new row, current count:", rowCount) 229 this.appendItemRow(item, rowCount + 1) 230 } 231 } 232 233 appendItemRow(item, rowNumber) { 234 const row = document.createElement('li') 235 row.className = 'item item-container' 236 row.setAttribute('data-item-id', item.id) 237 row.innerHTML = this.buildRowHtml(item, rowNumber) 238 this.itemsListTarget.appendChild(row) 239 } 240 241 buildRowHtml(item, rowNumber) { 242 const statusClass = this.itemStatusClass(item.status) 243 const textClass = item.status === 'WARNING' ? 'text-dark' : '' 244 const statusLabel = item.status === 'pending' ? 'Pending' : item.status 245 246 return ` 247 <div class="attribute" data-name="#">${rowNumber}</div> 248 <div class="attribute" data-name="Page URL" data-controller="clipboard"> 249 <div class="url-display"> 250 <a class="serp-icon" href="https://www.google.com/search?q=site:${encodeURIComponent(item.page_url)}" target="_blank" rel="noopener nofollow" title="Check in Google"> 251 <i class="bi bi-search"></i> 252 </a>
253 <span class="copy-icon" role="button" data-action="click->clipboard#copy" data-clipboard-text="${this.escapeHtml(item.page_url)}" title="Copy URL"> 254 <i class="bi bi-clipboard"></i> 255 </span> 256 <a class="url-link" href="${item.page_url}" target="_blank" rel="noopener nofollow" title="${this.escapeHtml(item.page_url)}"> 257 <span class="url-text">${this.escapeHtml(item.page_url)}</span> 258 <i class="bi bi-box-arrow-up-right ms-1 small"></i> 259 </a> 260 </div> 261 </div> 262 <div class="attribute" data-name="Target URL"> 263 <div class="url-display"> 264 <a class="url-link" href="${item.target_url}" target="_blank" rel="noopener nofollow" title="${this.escapeHtml(item.target_url)}"> 265 <span class="url-text">${this.escapeHtml(item.target_url)}</span> 266 <i class="bi bi-box-arrow-up-right ms-1 small"></i> 267 </a> 268 </div> 269 </div> 270 <div class="attribute" data-name="Anchor Text"> 271 <span class="text-muted small">${item.anchor_text ? this.truncateText(this.escapeHtml(item.anchor_text), 50) : 'â'}</span> 272 </div> 273 <div class="attribute" data-name="Status"> 274 <span class="badge bg-${statusClass} ${textClass}">${statusLabel}</span> 275 </div> 276 <div class="attribute" data-name="Issues">${item.issues ? item.issues.join(', ') : ''}</div> 277 ` 278 } 279 280 escapeHtml(text) { 281 if (!text) return '' 282 const div = document.createElement('div') 283 div.textContent = text 284 return div.innerHTML 285 } 286 287 truncateText(text, length) { 288 if (!text) return '' 289 return text.length > length ? text.substring(0, length - 3) + '...' : text 290 } 291 292 itemStatusClass(status) { 293 const classes = { 294 'OK': 'success', 295 'WARNING': 'warning', 296 'ERROR': 'danger', 297 'pending': 'secondary' 298 } 299 return classes[status] || 'secondary' 300 } 301 302 statusBadgeClass(status) { 303 const classes = { 304 'pending': 'secondary', 305 'extracting': 'info', 306 'validating': 'primary', 307 'completed': 'success', 308 'failed': 'danger' 309 } 310 return classes[status] || 'secondary' 311 } 312 313 humanizeStatus(status) { 314 return status.charAt(0).toUpperCase() + status.slice(1) 315 } 316};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.