1import { T, getCurrentLocale, isExtensionContext } from './i18n.js?v=M42h_fix17'; 2import { $, escapeHtml, getPricingUrl } from './helpers/dom.js?v=M42h_fix17'; 3import { 4 getTextColorForBg, 5 sanitizeSources, 6 extractDualSummary, 7 renderSimpleSummaryBullets, 8} from './helpers/formatters.js?v=M42h_fix17'; 9import { 10 showToast, 11 setStatus, 12 logToOutput, 13 setDisabled, 14 initTooltips, 15 getBadgeForSource, 16 showModal, 17} from './helpers/ui_components.js?v=M42h_fix17'; 18import { logEventSafe, bucketizePosition } from './analytics.js?v=M42h_fix17'; 19import { reportPresentationModeContext } from './presentation_mode_context.js?v=M42h_fix17'; 20import { renderEvidenceSignalBar } from './components/evidence_signal_bar.js?v=M42h_fix17'; 21import { createShareLink } from './api.js?v=M42h_fix17'; 22 23// Local cache to avoid circular dependency with handlers.js 24let cachedResultPayload = null; 25 26let shareActionsPromise = null; 27function loadShareActions() { 28 if (!shareActionsPromise) { 29 shareActionsPromise = import('./share_actions.js?v=M42h_fix17'); 30 } 31 return shareActionsPromise; 32} 33 34// Re-export for backward compatibility if needed by other modules 35export { 36 $, 37 getPricingUrl, 38 showToast, 39 setStatus, 40 logToOutput as log, 41 setDisabled as disable, 42 initTooltips, 43 44 showModal, 45 renderCheckResult, 46 renderLanguageSelector, 47 renderUserMenu, 48 toggleUserMenu, 49 toggleHistoryModal, 50 initDeepToggleListener, 51}; 52 53function buildDeepDetails(result) { 54 const claimResults = result?.deep_analysis?.claim_results; 55 if (!Array.isArray(claimResults) || claimResults.length === 0) return []; 56 const claims = Array.isArray(result?.claims) ? result.claims : []; 57 const alignByIndex = claims.length === claimResults.length; 58 59 return claimResults.map((cr, idx) => { 60 const text = alignByIndex 61 ? claims[idx] 62 : cr?.claim_text || cr?.claim_id || `Claim ${idx + 1}`; 63 // sources_used now contains full objects with trust info from backend 64 const sources = Array.isArray(cr?.sources_used) 65 ? cr.sources_used.map((s) => { 66 // If s is a string (old format), convert to object 67 if (typeof s === 'string') { 68 return { url: s, title: s }; 69 } 70 // New format: full object with url, domain, title, trust_category, is_trusted 71 return { 72 url: s.url || '', 73 link: s.url || '', 74 title: s.title || s.domain || 'Source', 75 domain: s.domain || '', 76 is_trusted: s.is_trusted || false, 77 trust_category: s.trust_category || null, 78 excerpt: s.citation_text || s.snippet || '', 79 }; 80 }) 81 : []; 82 const detail = { 83 text, 84 rgba: Array.isArray(cr?.rgba) ? cr.rgba : null, 85 rationale: cr?.explanation || '', 86 sources, 87 }; 88 if (cr?.status && cr.status !== 'ok') { 89 detail.error_key = cr?.error?.error_type || 'judge_failed'; 90 } 91 return detail; 92 }); 93} 94 95function buildDeepClaimVerdicts(result) { 96 const claimResults = result?.deep_analysis?.claim_results; 97 if (!Array.isArray(claimResults) || claimResults.length === 0) return []; 98 const claims = Array.isArray(result?.claims) ? result.claims : []; 99 const alignByIndex = claims.length === claimResults.length; 100 101 return claimResults.map((cr, idx) => { 102 const text = alignByIndex 103 ? claims[idx] 104 : cr?.claim_text || cr?.claim_id || `Claim ${idx + 1}`; 105 // sources_used now contains full objects with trust info from backend 106 const sources = Array.isArray(cr?.sources_used) 107 ? cr.sources_used.map((s) => { 108 if (typeof s === 'string') { 109 return { url: s, title: s }; 110 } 111 return { 112 url: s.url || '', 113 link: s.url || '', 114 title: s.title || s.domain || 'Source', 115 domain: s.domain || '', 116 is_trusted: s.is_trusted || false, 117 trust_category: s.trust_category || null, 118 excerpt: s.citation_text || s.snippet || '', 119 }; 120 }) 121 : []; 122 return { 123 claim_id: cr?.claim_id || '', 124 claim_text: cr?.claim_text || text, 125 text, 126 rgba: Array.isArray(cr?.rgba) ? cr.rgba : null, 127 status: cr?.status || '', 128 sources, 129 }; 130 }); 131} 132 133
134// --- Popup Logic --- 135 136function togglePopup(sentenceElement, idx) { 137 const popupId = 'popup-' + idx; 138 let wasActive = document.getElementById(popupId) !== null; 139 140 // T1.1: Don't close popup if there's only one result (prevents accidental closing) 141 const totalDetails = cachedResultPayload?.result?.details?.length || 0; 142 if (wasActive && totalDetails === 1) return; // Keep popup open for single result 143 144 document.querySelectorAll('.popup-container').forEach((p) => p.remove()); 145 146 if (wasActive) return; 147 148 // Analytics: result_expand_claim 149 try { 150 const res = cachedResultPayload?.result; 151 const isDeep = !!( 152 res?.deep_analysis || 153 res?.mode === 'deep' || 154 res?.analysis_mode === 'deep' 155 ); 156 logEventSafe('result_expand_claim', { 157 mode: isDeep ? 'deep' : 'standard', 158 position_bucket: bucketizePosition(idx), 159 }); 160 } catch (e) { } 161 162 // Use local cache instead of importing lastCheckResult 163 164 const data = cachedResultPayload?.result?.details?.[idx]; 165 if (!data) return; 166 167 168 const _cleanedSources = sanitizeSources(data.sources); 169 const sourcesHtml = _cleanedSources.length 170 ? `<ul>${_cleanedSources 171 .map((s) => { 172 let originText; 173 if (s.origin === 'RAG') { 174 originText = T('popup.source_origin_rag'); 175 } else if (s.origin === 'GOOGLE_FACT_CHECK') { 176 originText = T('popup.source_origin_google'); 177 } else { 178 originText = T('popup.source_origin_web'); 179 } 180 181 182 const badge = getBadgeForSource(s, false); 183 const trustedClass = s.is_trusted 184 ? 'trusted-source' 185 : 'untrusted-source'; 186 187 188 if (s.link) { 189 return `<li class="${trustedClass}">${badge}<a href="${s.link}" target="_blank" rel="noopener noreferrer">${escapeHtml(s.title)}</a> <span class="source-origin">(${escapeHtml(originText)})</span></li>`; 190 } else { 191 return `<li><span>${escapeHtml(s.title)}</span> <span class="source-origin">(${escapeHtml(originText)})</span></li>`; 192 } 193 }) 194 .join('')}</ul>` 195 : `<p>${T('popup.no_sources')}</p>`; 196 197 // Defensive fallback: if rgba is missing or malformed, use neutral defaults 198 const rgba = 199 Array.isArray(data.rgba) && data.rgba.length >= 4 200 ? data.rgba 201 : [0, -1, 0, 0.5]; 202 const [r, g, b, a] = rgba; 203 const fmtScore = (v) => 204 typeof v === 'number' && v >= 0 205 ? `${Math.round(v * 100)}%` 206 : T('popup.not_available') || 'N/A'; 207 const barWidth = (v) => 208 typeof v === 'number' && v >= 0 ? `${v * 100}%` : '0%'; 209 const barClass = (v) => 210 typeof v === 'number' && v >= 0 ? '' : 'score-bar-na'; 211 // Use per-claim rationale if available (deep mode), fall back to global summary 212 const claimRationale = (data.rationale || '').trim(); 213 const globalExpert = cachedResultPayload?.result ? extractDualSummary(cachedResultPayload.result) : null; 214 const expertText = claimRationale || globalExpert?.expert || T('popup.no_rationale') || 'No detailed summary available.'; 215 const summaryTitle = T('popup.rationale_title') || 'Summary'; 216 217 /** Format LLM explanation for display: localize markers, add line breaks */ 218 const formatExplanation = (text) => { 219 let s = escapeHtml(text); 220 // Replace raw LLM prompt artifact 'simple_summary:' with localized heading 221 const summaryLabel = T('popup.simple_summary_title') || 'Summary'; 222 s = s.replace(/\bsimple_summary\s*:/gi, `<strong>${escapeHtml(summaryLabel)}:</strong>`); 223 // Convert newlines to <br> for readable formatting 224 s = s.replace(/\n/g, '<br>'); 225 return s; 226 }; 227 228 const popupInnerHtml = ` 229 <h4>${T('popup.details_title')}</h4> 230 <div class="score-bar-container"> 231 <span>${T('popup.danger_label')} ${fmtScore(r)}</span> 232 <button type="button" class="info-btn" aria-label="Info" data-tooltip="${T('info.danger_tooltip')}"></button> 233 <div class="score-bar ${barClass(r)}"><div style="width: ${barWidth(r)}; background-color: rgba(255, 0, 0, 0.7);"></div></div> 234 </div> 235 ${renderEvidenceSignalBar({ 236 gScore: g, 237 compact: true, 238 infoTooltip: T('info.veracity_tooltip'), 239 titleKey: 'popup.veracity_label', 240 strengthPrefixKey: 'report.signal_strength', 241 })} 242 <div class="score-bar-container">
243 <span>${T('popup.honesty_label')} ${fmtScore(b)}</span> 244 <button type="button" class="info-btn" aria-label="Info" data-tooltip="${T('info.honesty_tooltip')}"></button> 245 <div class="score-bar ${barClass(b)}"><div style="width: ${barWidth(b)}; background-color: rgba(0, 0, 255, 0.7);"></div></div> 246 </div> 247 <div class="score-bar-container"> 248 <span>${T('popup.confidence_label')} ${fmtScore(a)}</span> 249 <button type="button" class="info-btn" aria-label="Info" data-tooltip="${T('info.confidence_tooltip')}"></button> 250 <div class="score-bar ${barClass(a)}"><div style="width: ${barWidth(a)}; background-color: rgba(128, 128, 128, 0.7);"></div></div> 251 </div> 252 <div class="report-summary-card expert-summary popup-rationale" style="margin-top: 20px; margin-bottom: 20px;"> 253 <h4>${escapeHtml(summaryTitle)}</h4> 254 <p class="report-expert-summary">${formatExplanation(expertText)}</p> 255 </div> 256 <details class="claim-sources"> 257 <summary>${T('popup.sources_title') || 'Sources'} (${_cleanedSources.length})</summary> 258 ${sourcesHtml} 259 </details> 260 `; 261 262 const newPopupContainer = document.createElement('div'); 263 newPopupContainer.id = popupId; 264 newPopupContainer.className = 'popup-container'; 265 newPopupContainer.innerHTML = `<div class="popup">${popupInnerHtml}</div>`; 266 sentenceElement.parentElement.after(newPopupContainer); 267} 268 269window.togglePopup = togglePopup; 270 271// New function to handle user menu rendering, including presentation mode logic 272 273 274 275function renderCheckResult(payload) { 276 // Update local cache whenever we render 277 cachedResultPayload = payload; 278 279 const box = document.getElementById('check-out'); 280 if (!box) return; 281 box.style.display = 'block'; 282 283 const res = payload?.result || null; 284 const status = payload?.status || 'unknown'; 285 const parsedText = payload?.parsed_text || null; // M8.2: Backend-parsed text 286 287 if (res?.error_key) { 288 let key = res.error_key; 289 // Normalize keys: 'app.error_foo' -> 'foo', 'app.error.foo' -> 'foo' 290 if (key.startsWith('app.error_')) { 291 key = key.replace('app.error_', ''); 292 } else if (key.startsWith('app.error.')) { 293 key = key.replace('app.error.', ''); 294 } 295 296 let message = T(`error.${key}`, res); 297 // Fallback if translation missing 298 if (message.startsWith('error.')) { 299 // Try exact key if normalization failed to find a match 300 if (key !== res.error_key) { 301 const directMsg = T(`error.${res.error_key}`); 302 if (!directMsg.startsWith('error.')) message = directMsg; 303 } 304 } 305 306 if (message.startsWith('error.')) { 307 message = T('error.generic_backend_error', { error: res.error_key }); 308 } 309 box.innerHTML = `<div class="loader"><span>${escapeHtml(message)}</span></div>`; 310 return; 311 } 312 313 // Check for deep mode results (needs to be accessible throughout the function) 314 const hasDeepResults = 315 Array.isArray(res?.deep_analysis?.claim_results) && 316 res.deep_analysis.claim_results.length > 0; 317 318 if (status === 'done' && res) { 319 if (hasDeepResults) { 320 const deepDetails = buildDeepDetails(res); 321 if (deepDetails.length) { 322 res.details = deepDetails; 323 } 324 const deepVerdicts = buildDeepClaimVerdicts(res); 325 if (deepVerdicts.length) { 326 res.claim_verdicts = deepVerdicts; 327 } 328 } else { 329 if (!Array.isArray(res.details) || res.details.length === 0) { 330 const deepDetails = buildDeepDetails(res); 331 if (deepDetails.length) { 332 res.details = deepDetails; 333 } 334 } 335 if ( 336 !Array.isArray(res.claim_verdicts) || 337 res.claim_verdicts.length === 0 338 ) { 339 const deepVerdicts = buildDeepClaimVerdicts(res); 340 if (deepVerdicts.length) { 341 res.claim_verdicts = deepVerdicts; 342 } 343 } 344 } 345 } 346 347 if (status === 'done' && res?.details) { 348 let finalHtml = `<h3>${res.article_title ? escapeHtml(res.article_title) : T('results.title')}</h3>`; 349 350 // M8.2: Show parsed text metadata if available 351 if (parsedText) { 352 const metaInfo = []; 353 if (parsedText.title) 354 metaInfo.push( 355 `<strong>${T('results.parsed_title')}:</strong> ${escapeHtml(parsedText.title)}` 356 ); 357 if (parsedText.authors && parsedText.authors.length > 0) { 358 metaInfo.push( 359 `<strong>${T('results.authors')}:</strong> ${escapeHtml(parsedText.authors.join(', '))}` 360 ); 361 } 362 if (parsedText.publish_date) { 363 const date = new Date(parsedText.publish_date); 364 metaInfo.push( 365 `<strong>${T('results.published')}:</strong> ${date.toLocaleDateString()}` 366 ); 367 } 368 369 if (metaInfo.length > 0) { 370 finalHtml += `<div class="parsed-text-info">${metaInfo.join(' ⢠')}</div>`; 371 } 372 } 373 374 if (res?.fallback) { 375 finalHtml += `<div class="fallback-notice">${escapeHtml(T('results.fallback_message'))}</div>`; 376 } 377 378 const searchInfo = res?.search || null; 379 if (searchInfo && searchInfo.quality === 'poor') { 380 const currentProvider = searchInfo.provider || 'auto'; 381 const suggestedProvider = 382 currentProvider === 'google_cse' ? 'tavily' : 'google_cse'; 383 const title = 384 T('results.search_quality_low_title') || 'Low search quality'; 385 const body = 386 T('results.search_quality_low_body') || 387 'The web search results look off-topic. You can retry with an alternative provider.'; 388 const btnLabel = 389 suggestedProvider === 'google_cse' 390 ? T('results.retry_with_google') || 'Retry with Google' 391 : T('results.retry_with_tavily') || 'Retry with Tavily'; 392 finalHtml += ` 393 <div class="search-quality-notice"> 394 <div class="search-quality-text"> 395 <strong>${escapeHtml(title)}</strong><br/>
396 <span>${escapeHtml(body)}</span> 397 </div> 398 <button id="retry-search-button" class="button secondary small" data-provider="${escapeHtml( 399 suggestedProvider 400 )}">${escapeHtml(btnLabel)}</button> 401 </div>`; 402 } 403 404 if (searchInfo && searchInfo.budget_limited) { 405 const title = T('results.budget_limited_title') || 'Limited by budget'; 406 const body = 407 T('results.budget_limited_body') || 408 'Some optional search steps were skipped to fit your current credit balance. The result may be less accurate.'; 409 finalHtml += ` 410 <div class="search-quality-notice"> 411 <div class="search-quality-text"> 412 <strong>${escapeHtml(title)}</strong><br /> 413 <span>${escapeHtml(body)}</span> 414 </div> 415 </div>`; 416 } 417 418 // T1.2: Display anchor claim (main claim being verified) 419 const anchorClaim = res?.anchor_claim; 420 // M140: Implementation of Dual Presentation Modes 421 const mode = reportPresentationModeContext.getMode(); 422 const isDeep = !!( 423 res?.deep_analysis || 424 res?.mode === 'deep' || 425 res?.analysis_mode === 'deep' 426 ); 427 const isExpert = mode === 'expert' || isDeep; 428 const dualSummary = extractDualSummary(res); 429 430 if (anchorClaim?.text && !isExpert) { 431 const anchorLabel = 432 T('results.anchor_claim_label') || 'Main claim being verified'; 433 finalHtml += ` 434 <div class="anchor-claim-notice"> 435 <span class="anchor-label">${escapeHtml(anchorLabel)}:</span> 436 <span class="anchor-text">${escapeHtml(anchorClaim.text)}</span> 437 </div>`; 438 } 439 440 // 1. Evidence Signal Bar 441 // 1. Evidence Signal Bar 442 if (!isExpert) { 443 const gScore = 444 Array.isArray(res?.rgba) && res.rgba.length >= 2 ? res.rgba[1] : null; 445 finalHtml += renderEvidenceSignalBar({ 446 gScore, 447 titleKey: 'report.evidence_signal', 448 strengthPrefixKey: 'report.signal_strength', 449 }); 450 } 451 452 // 2. Summary Section 453 const summaryTitle = T('popup.rationale_title') || 'Summary'; 454 if (isExpert) { 455 // Expert Mode: Global expert summary is now in the popup 456 } else { 457 // Default Mode: Bullets 458 const summarySimpleHtml = renderSimpleSummaryBullets(dualSummary.simple); 459 if (summarySimpleHtml) { 460 finalHtml += ` 461 <div class="report-summary-card default-summary"> 462 <h4>${escapeHtml(summaryTitle)}</h4> 463 ${summarySimpleHtml} 464 </div> 465 `; 466 } 467 } 468 469 470 471 // Show all claims that contributed to final RGBA scoring 472 const anchorText = anchorClaim?.text 473 ? anchorClaim.text.trim().toLowerCase() 474 : null; 475 const claimVerdicts = Array.isArray(res?.claim_verdicts) 476 ? res.claim_verdicts 477 : []; 478 const claimItems = claimVerdicts 479 .map((cv) => { 480 if (!cv) return null; 481 const text = cv.claim_text || cv.claim || cv.text; 482 if (!text) return null; 483 if (anchorText && text.trim().toLowerCase() === anchorText) return null; // avoid duplicating anchor claim 484 const verdictLabel = (cv.verdict || cv.status || '').toString(); 485 const verdictClass = verdictLabel.toLowerCase(); 486 const score = 487 typeof cv.verdict_score === 'number' && cv.verdict_score >= 0 488 ? Math.round(cv.verdict_score * 100) 489 : null; 490 const claimId = cv.claim_id || ''; 491 492 // Per-claim RGBA 493 const rgba = cv.rgba || [0, 0, 0, 0.3]; 494 const [r, g, b, a] = rgba; 495 const barStyle = (v) => 496 `width: ${Math.max(0, Math.min(100, v * 100))}%; `; 497 498 // Per-claim Sources 499 const _cleanedSources = sanitizeSources(cv.sources); 500 const sourcesHtml = _cleanedSources.length 501 ? `<ul class="claim-sources-list">${_cleanedSources 502 .map((s) => { 503 const domain = escapeHtml(s.domain || s.title || 'Source'); 504 505 // Determine badge (simplified for claim card view) 506 let icon = 'ð'; // Default Web 507 let tooltip = 'Web'; 508 509 if (s.trust_category) { 510 // Map known categories to icons directly 511 const badgeMap = { 512 science_and_health: 'ð¬', 513 ukraine_imi_whitelist: 'ðºð¦', 514 fact_checking_ifcn: 'â ', 515 technology: 'ð»', 516 russia_independent_exiled: 'ðï¸', 517 global_news_agencies: 'ð°', 518 general_news_western: 'ð¡ï¸', 519 europe_tier1: 'ðªðº', 520 asia_pacific: 'ð', 521 astronomy_tier_a: 'ð', 522 astronomy_tier_b: 'ð', 523 astronomy_tier_c: 'ð', 524 general_news_ukraine_broad: 'ðºð¦', 525 finance_tier1: 'ð', 526 education_tier1: 'ð', 527 law_tier1: 'âï¸', 528 international_public_bodies: 'ðï¸', 529 }; 530 if (badgeMap[s.trust_category]) { 531 icon = badgeMap[s.trust_category]; 532 tooltip = s.trust_category; // Simplified label 533 } 534 } else if (s.is_trusted) { 535 icon = 'ð¡ï¸'; 536 tooltip = 'Trusted'; 537 } else {
538 // Check social 539 const d = (s.domain || '').toLowerCase(); 540 if ( 541 [ 542 'facebook', 543 'twitter', 544 'x.com', 545 'instagram', 546 'youtube', 547 'tiktok', 548 'telegram', 549 'linkedin', 550 'reddit', 551 ].some((p) => d.includes(p)) 552 ) { 553 icon = 'ð¬'; 554 tooltip = 'Social'; 555 } 556 } 557 558 const badgeHtml = `<span class="source-badge mini" title="${escapeHtml(tooltip)}">${icon}</span>`; 559 const linkUrl = s.link || s.url; 560 const linkLabel = domain || s.title || 'Source'; 561 const link = linkUrl 562 ? `${badgeHtml}<a href="${escapeHtml(linkUrl)}" target="_blank" rel="noopener noreferrer">${linkLabel}</a>` 563 : `${badgeHtml}${linkLabel}`; 564 565 // Show excerpt if available 566 let excerptHtml = ''; 567 const snippet = s.excerpt || s.content_excerpt; 568 if (snippet) { 569 excerptHtml = `<div class="source-excerpt">${escapeHtml(snippet)}</div>`; 570 } 571 572 return `<li>${link}${excerptHtml}</li>`; 573 }) 574 .join('') 575 }</ul>` 576 : ''; 577 578 return ` 579 <div class="claim-card" data-verdict="${escapeHtml(verdictClass)}"> 580 <div class="claim-card-top">
581 ${claimId ? `<span class="claim-card-id">${escapeHtml(claimId)}</span>` : ''} 582 ${verdictLabel ? `<span class="claim-card-verdict">${escapeHtml(verdictLabel)}</span>` : ''} 583 ${score !== null ? `<span class="claim-card-score">${escapeHtml(score)}%</span>` : `<span class="claim-card-score na">N/A</span>`} 584 </div> 585 <div class="claim-card-text">${escapeHtml(text)}</div> 586 587 <div class="claim-card-rgba"> 588 <div class="mini-bar danger" style="${barStyle(r)}" title="${T('popup.danger_label')}"></div> 589 <div class="mini-bar veracity" style="${barStyle(g)}" title="${T('popup.veracity_label')}"></div> 590 <div class="mini-bar honesty" style="${barStyle(b)}" title="${T('popup.honesty_label')}"></div> 591 <div class="mini-bar confidence" style="${barStyle(a)}" title="${T('popup.confidence_label')}"></div> 592 </div> 593 594 ${sourcesHtml 595 ? ` 596 <details class="claim-sources"> 597 <summary>${T('popup.sources_title') || 'Sources'} (${_cleanedSources.length})</summary> 598 ${sourcesHtml} 599 </details> 600 ` 601 : '' 602 } 603 </div > 604 `; 605 }) 606 .filter(Boolean); 607 608 // Only show claims accordion in standard mode (secondary claims) 609 // In deep mode, claims are shown as individual sentences with full RGBA 610 if (claimItems.length) { 611 const claimsLabel = 612 T('results.claims_evaluated_label') || 'Secondary evaluated claims'; 613 const claimsCount = claimItems.length; 614 finalHtml += ` 615 <details class="claims-list claims-accordion"> 616 <summary class="claims-list-header"> 617 <span class="claims-list-title">${escapeHtml(claimsLabel)}</span> 618 <span class="claims-list-count">${escapeHtml(String(claimsCount))}</span> 619 </summary> 620 <div class="claims-list-body"> 621 ${claimItems.join('')} 622 </div> 623 </details> 624 `; 625 } 626 627 628 629 // Expert Mode: Show per-sentence highlights 630 // Default Mode: Show standard breakdown 631 if (isExpert && res.details) { 632 // Individual sentences with RGBA backgrounds 633 res.details.forEach((d, idx) => { 634 if (d.error_key) { 635 finalHtml += `<div class="sentence-container"><span class="sentence-error">${escapeHtml(d.text)}</span></div> `; 636 return; 637 } 638 const rgba = 639 Array.isArray(d.rgba) && d.rgba.length >= 4 ? d.rgba : [0, 0, 0, 0.35]; 640 const [r, g, b, a] = rgba; 641 const textColor = getTextColorForBg(r, g, b); 642 finalHtml += ` 643 <div class="sentence-container"> 644 <span class="sentence" 645 data-idx="${idx}" 646 style="background-color: rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${Math.max(a, 0.25)}); color: ${textColor};"> 647 ${escapeHtml(d.text)} 648 </span> 649 </div> `; 650 }); 651 } else { 652 // Default Mode: Simplified view of secondary claims (if any) 653 const secondaryClaims = res.claim_verdicts || []; 654 if (secondaryClaims.length > 0) { 655 finalHtml += `<div class="secondary-claims-section" style="margin-top: 20px;">`; 656 finalHtml += `<h4 style="margin-bottom: 12px;">${T('results.claims_breakdown') || 'Claims Breakdown'}</h4>`; 657 secondaryClaims.forEach((c) => { 658 const rgba = Array.isArray(c.rgba) ? c.rgba : [0, 0, 0, 0.3]; 659 const [r, g, b, a] = rgba; 660 const pct = Math.round((g || 0) * 100); 661 finalHtml += ` 662 <div class="claim-card-simple"> 663 <div class="claim-status-dot" style="background-color: rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1);"></div> 664 <div class="claim-text">${escapeHtml(c.claim_text || c.text || '')}</div> 665 <div class="claim-score-mini">${pct}%</div> 666 </div>`; 667 }); 668 finalHtml += `</div>`; 669 } 670 } 671 672 // Show actual verification cost (moved to bottom) 673 // Priority: billing.charged_total (paid) > billing.subsidy_cost (free/subsidized) > cost_summary (raw fallback)
674 let creditsToShow = res?.billing?.charged_total; 675 676 // If charged_total is 0 or missing, try to show subsidy details 677 if ( 678 (!creditsToShow || Number(creditsToShow) === 0) && 679 res?.billing?.subsidy_cost 680 ) { 681 creditsToShow = res.billing.subsidy_cost; 682 } 683 684 const costSummary = res?.billing?.cost_summary || res?.cost_summary; 685 const rawEngineCredits = costSummary?.total_credits; 686 687 // If billing is 0/missing but engine shows non-zero usage, prefer engine usage (estimated/raw) 688 if ( 689 (creditsToShow === undefined || 690 creditsToShow === null || 691 Number(creditsToShow) === 0) && 692 rawEngineCredits !== undefined && 693 rawEngineCredits !== null 694 ) { 695 const n = Number(rawEngineCredits); 696 if (!Number.isNaN(n) && n > 0) { 697 creditsToShow = Math.ceil(n); 698 } 699 } 700 701 // Final fallback: only if billing info is missing completely 702 if (creditsToShow === undefined || creditsToShow === null) { 703 creditsToShow = rawEngineCredits; 704 if (creditsToShow !== undefined) 705 creditsToShow = Math.ceil(Number(creditsToShow)); 706 } 707 708 if (creditsToShow !== undefined && creditsToShow !== null) { 709 const displayValue = creditsToShow; 710 const label = T('results.credits_spent') || 'Credits spent'; 711 finalHtml += `<div class="credits-spent-info"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(String(displayValue))}</div>`; 712 } 713 714 const hasClaimGraph = 715 res?.deep_analysis?.claim_graph && 716 Array.isArray(res.deep_analysis.claim_graph.nodes) && 717 res.deep_analysis.claim_graph.nodes.length > 0; 718 719 finalHtml += ` 720 <div class="share-actions"> 721 <button id="copy-report-button" class="button secondary small">${T('button.copy_report')}</button> 722 <button id="share-button" class="button secondary small">${T('button.share')}</button> 723 <button id="share-image-button" class="button secondary small">${T('button.share_image')}</button>${hasClaimGraph ? `<button type="button" id="tree-link-button" class="button secondary small">${escapeHtml(T('share.view_3d_tree_short') || '3D tree')}</button>` : ''} 724 </div>`; 725 726 // 3. Expectation notice (moved after share buttons) 727 const expectationNotice = T('results.expectation_notice'); 728 if (expectationNotice) { 729 finalHtml += `<div class="expectation-notice"><svg class="icon" aria-hidden="true"><use href="#info"></use></svg>${escapeHtml(expectationNotice)}</div>`; 730 } 731 732 // M41: Add "Powered by Spectrue" badge for free users 733 if (res?.meta?.powered_by_spectrue) { 734 // Use pricing page as fallback - it's public and doesn't require auth 735 const link = 736 res.meta.attribution_link || 'https://spectrue.net/pricing.html'; 737 finalHtml += ` 738 <div class="spectrue-badge"> 739 <a href="${link}" target="_blank" rel="noopener noreferrer">Powered by Spectrue</a> 740 </div>`; 741 } 742 743 box.innerHTML = finalHtml; 744 } 745 746 // Attach click listeners to sentences (CSP safe) 747 box.querySelectorAll('.sentence').forEach((el) => { 748 el.addEventListener('click', () => { 749 const idx = parseInt(el.getAttribute('data-idx'), 10); 750 togglePopup(el, idx); 751 }); 752 }); 753 754 const copyButton = $('copy-report-button'); 755 if (copyButton) { 756 copyButton.addEventListener('click', async () => { 757 const { copyTextReport } = await loadShareActions(); 758 await copyTextReport(cachedResultPayload, { button: copyButton }); 759 logEventSafe('result_share', { 760 channel: 'copy', 761 mode: isDeep ? 'deep' : 'standard', 762 }); 763 }); 764 } 765 766 const shareButton = $('share-button'); 767 if (shareButton) { 768 shareButton.addEventListener('click', async () => { 769 const { shareReport } = await loadShareActions(); 770 await shareReport(cachedResultPayload, { button: shareButton }); 771 logEventSafe('result_share', { 772 channel: 'other', 773 mode: isDeep ? 'deep' : 'standard', 774 }); 775 }); 776 } 777 778 const shareImageButton = $('share-image-button'); 779 if (shareImageButton) { 780 shareImageButton.addEventListener('click', async () => { 781 const { shareOgImage } = await loadShareActions(); 782 await shareOgImage(cachedResultPayload, { button: shareImageButton }); 783 logEventSafe('result_share', { 784 channel: 'image', 785 mode: isDeep ? 'deep' : 'standard', 786 }); 787 }); 788 } 789 790 const treeLinkButton = $('tree-link-button'); 791 if (treeLinkButton && cachedResultPayload) { 792 treeLinkButton.addEventListener('click', async () => { 793 const checkId = cachedResultPayload.id || cachedResultPayload.result?.id; 794 // Prefer opening by report id: create share link then go to /tree/{shareId}
795 try { 796 if (checkId) { 797 const shareRes = await createShareLink(checkId); 798 if (shareRes?.ok && shareRes?.data?.share_id) { 799 window.location.href = `/tree/${shareRes.data.share_id}`; 800 return; 801 } 802 } 803 } catch (err) { 804 console.warn('Share link for tree failed', err); 805 } 806 // Fallback: pass data via sessionStorage and open /tree?check_id=... 807 const treeUrl = checkId ? `/tree?check_id=${encodeURIComponent(checkId)}` : '/tree'; 808 try { 809 const result = cachedResultPayload.result ?? cachedResultPayload; 810 const payload = { analysis_result: result, source_info: {} }; 811 if (checkId) payload.check_id = checkId; 812 sessionStorage.setItem('spectrue_tree_data', JSON.stringify(payload)); 813 } catch (err) { 814 console.warn('Tree data save failed', err); 815 } 816 window.location.href = treeUrl; 817 }); 818 } 819 820 const retrySearchButton = $('retry-search-button'); 821 if (retrySearchButton) { 822 retrySearchButton.addEventListener('click', async () => { 823 const provider = 824 retrySearchButton.getAttribute('data-provider') || 'auto'; 825 retrySearchButton.disabled = true; 826 try { 827 const { handleStartCheck } = await import('./handlers.js?v=M42h_fix17'); 828 await handleStartCheck({ search_provider: provider }); 829 } finally { 830 retrySearchButton.disabled = false; 831 } 832 }); 833 } 834 835 // Analytics: track source link clicks via delegation 836 box.addEventListener('click', (e) => { 837 const link = e.target.closest('a'); 838 if (link && (link.closest('.popup') || link.closest('.claim-card'))) { 839 let kind = 'other'; 840 const hostname = (link.hostname || '').toLowerCase(); 841 if (hostname.includes('wikipedia.org')) kind = 'wikipedia'; 842 else if (hostname.includes('.gov')) kind = 'gov'; 843 else if ( 844 [ 845 'facebook.com', 846 'x.com', 847 'twitter.com', 848 'instagram.com', 849 'youtube.com', 850 'tiktok.com', 851 'telegram.org', 852 'reddit.com', 853 ].some((s) => hostname.includes(s)) 854 ) 855 kind = 'social'; 856 else if ( 857 [ 858 'news.', 859 'reuters.com', 860 'apnews.com', 861 'bbc.com', 862 'cnn.com', 863 'nytimes.com', 864 'guardian.com', 865 ].some((s) => hostname.includes(s)) 866 ) 867 kind = 'news'; 868 869 const res = cachedResultPayload?.result; 870 const isDeep = !!( 871 res?.deep_analysis || 872 res?.mode === 'deep' || 873 res?.analysis_mode === 'deep' 874 ); 875 876 logEventSafe('result_open_evidence', { 877 source_kind: kind, 878 mode: isDeep ? 'deep' : 'standard', 879 }); 880 } 881 }); 882 883 // Auto-open first sentence details 884 885 const firstSentence = box.querySelector('.sentence'); 886 if (firstSentence) { 887 firstSentence.click(); 888 } 889 890 // Analytics: page view for result 891 try { 892 logEventSafe('page_view_custom', { page_id: 'result' }); 893 } catch (e) { } 894 895 return; 896} 897 898function renderLanguageSelector() { 899 const container = document.getElementById('header-lang-container'); 900 if (!container) return; 901 902 const currentLang = getCurrentLocale(); 903 const options = [ 904 { value: 'en', label: 'ð¬ð§ English' }, 905 { value: 'uk', label: 'ðºð¦ УкÑаÑнÑÑка' }, 906 { value: 'de', label: 'ð©ðª Deutsch' }, 907 { value: 'es', label: 'ðªð¸ Español' }, 908 { value: 'fr', label: 'ð«ð· Français' }, 909 { value: 'ja', label: 'ð¯ðµ æ¥æ¬èª' }, 910 { value: 'zh', label: 'ð¨ð³ 䏿' }, 911 { value: 'ru', label: 'ð·ðº Ð ÑÑÑкий' }, 912 ]; 913 914 container.innerHTML = ` 915 <div class="header-lang-selector"> 916 <select id="header-lang-select" class="header-lang-select"> 917 ${options.map(opt => ` 918 <option value="${opt.value}" ${currentLang === opt.value ? 'selected' : ''}> 919 ${opt.label} 920 </option> 921 `).join('')} 922 </select> 923 </div> 924 `; 925} 926 927function renderUserMenu(user) { 928 const container = document.getElementById('user-menu-container'); 929 if (!container) return; 930 931 const sidebarBalance = document.getElementById('sidebar-balance-display'); 932 933 if (!user) { 934 container.innerHTML = ''; 935 const btn = document.createElement('button'); 936 btn.id = 'login-button'; 937 btn.className = 'button small primary'; 938 btn.textContent = T('sidebar.login') || 'Login'; 939 btn.onclick = () => { 940 // Analytics: auth start 941 logEventSafe('auth_start', { method: 'google' }); 942 943 btn.innerHTML = '<span class="spinner-small"></span>'; // Show loading state 944 setTimeout(() => { 945 const nextUrl = encodeURIComponent(window.location.href); 946 const authParams = new URLSearchParams(); 947 authParams.set('next', nextUrl); 948 if (isExtensionContext()) { 949 authParams.set('platform', 'extension'); 950 authParams.set('lang', getCurrentLocale()); 951 } 952 try { 953 window.location.href = `auth.html?${authParams.toString()}`; 954 } catch (e) { 955 // Fallback 956 window.open(`auth.html?${authParams.toString()}`, '_blank'); 957 } 958 }, 50); 959 }; 960 container.appendChild(btn); 961 962 // Hide balance for guests 963 if (sidebarBalance) sidebarBalance.style.display = 'none'; 964 return; 965 } 966 967 // Show balance for logged-in users 968 if (sidebarBalance) sidebarBalance.style.display = 'block'; 969 970 const email = user.email || 'User'; 971 const photoURL = 972 user.photoURL ||
973 `https://ui-avatars.com/api/?name=${encodeURIComponent(email)}&background=random`; 974 const credits = user.credits !== undefined ? user.credits : '...'; 975 const plan = user.plan || 'free'; 976 977 // Check current theme/lang to set active states if needed (though simple toggle is enough) 978 const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; 979 const currentLang = getCurrentLocale(); 980 981 // Populate Header Badges 982 const getPricingUrl = () => { 983 const params = new URLSearchParams(window.location.search); 984 const platform = params.get('platform'); 985 return platform ? `pricing.html?platform=${platform}` : 'pricing.html'; 986 }; 987 988 const planBadge = document.getElementById('header-plan-badge'); 989 if (planBadge) { 990 planBadge.textContent = plan; 991 planBadge.className = `header-badge badge-${plan}`; 992 planBadge.classList.remove('hidden'); 993 planBadge.onclick = () => (window.location.href = getPricingUrl()); 994 } 995 996 const creditsBadge = document.getElementById('header-credits-badge'); 997 if (creditsBadge) { 998 const totalCredits = credits; 999 creditsBadge.textContent = `${totalCredits} cr`; 1000 creditsBadge.classList.remove('hidden'); 1001 creditsBadge.onclick = () => (window.location.href = getPricingUrl()); 1002 1003 1004 // Hover logic for breakdown 1005 const breakdown = user.credits_breakdown || {}; 1006 const paid = parseFloat( 1007 breakdown.paid_credits_sc || 1008 user.paid_credits_sc || 1009 user.balance_sc || 1010 '0' 1011 ); 1012 const free = parseFloat( 1013 breakdown.free_credits_sc || 1014 user.free_credits_sc || 1015 user.available_sc || 1016 '0' 1017 ); 1018 1019 // Only add hover if we have a valid breakdown that sums conceptually to something relevant 1020 if (paid >= 0 || free >= 0) { 1021 const originalText = `${totalCredits} cr`; 1022 1023 // Allow some tolerance for float precision 1024 const paidInt = Math.floor(paid); 1025 const freeInt = Math.floor(free); 1026 1027 creditsBadge.onmouseenter = () => { 1028 // paid (green/gold) + free (cyan/blue) + info icon 1029 creditsBadge.innerHTML = ` 1030 <div style="display: flex; align-items: center; gap: 4px;"> 1031 <span style="color: var(--color-success); font-weight: bold;">${paidInt}</span> 1032 <span style="opacity: 0.5;">+</span> 1033 <span style="color: var(--color-primary); font-weight: bold;">${freeInt}</span> 1034 <span id="credits-info-icon" class="credits-info-icon">i</span> 1035 </div> 1036 `; 1037 1038 // Attach listener to icon once element is in DOM 1039 const icon = document.getElementById('credits-info-icon'); 1040 if (icon) { 1041 icon.onclick = (e) => { 1042 e.stopPropagation(); // prevent navigation to pricing 1043 e.preventDefault(); 1044 1045 const title = T('credits.breakdown_title'); 1046 const content = ` 1047 <div style="margin-bottom: 12px;"> 1048 <strong style="color: var(--color-success);">${T('credits.paid_label')}</strong> 1049 <p style="margin: 4px 0 0 0; opacity: 0.8; font-size: 0.9em;">${T('credits.paid_desc')}</p> 1050 </div> 1051 <div> 1052 <strong style="color: var(--color-primary);">${T('credits.free_label')}</strong> 1053 <p style="margin: 4px 0 0 0; opacity: 0.8; font-size: 0.9em;">${T('credits.free_desc')}</p> 1054 </div> 1055 `; 1056 showModal(title, content); 1057 }; 1058 } 1059 }; 1060 creditsBadge.onmouseleave = () => { 1061 creditsBadge.textContent = originalText; 1062 }; 1063 } else { 1064 creditsBadge.onmouseenter = null; 1065 creditsBadge.onmouseleave = null; 1066 } 1067 1068 } 1069 1070 // Populate Sidebar Balance 1071 if (sidebarBalance) { 1072 // Use T() if available for "Balance", otherwise fallback 1073 const label = T('credits.balance') || 'Balance'; 1074 sidebarBalance.innerHTML = `<div>${label}: ${credits}</div>`; 1075 1076 // Wallet Details Stats (Debug only for localhost) 1077 const isLocal = ['localhost', '127.0.0.1'].includes( 1078 window.location.hostname 1079 ); 1080 1081 if (isLocal && (user.wallet || user.pool)) { 1082 let detailsHtml = `<div style="margin-top:8px; padding-top:8px; border-top:1px dashed var(--border-default); font-size: 0.8em; opacity: 0.8;">`; 1083 1084 if (user.wallet) { 1085 const w = user.wallet; 1086 // Check API response structure or fallback to top-level aliases 1087 const paid = user.paid_credits_sc || w.balance_sc || '0'; 1088 const free = user.free_credits_sc || w.available_sc || '0'; 1089 1090 detailsHtml += `<div><strong>Wallet:</strong></div>`; 1091 detailsHtml += `<div style="color: var(--color-success);">Paid: ${paid}</div>`; 1092 detailsHtml += `<div style="color: var(--color-primary);">Free: ${free}</div>`; 1093 1094 // Show bonus info if relevant 1095 if (w.daily_bonus) 1096 detailsHtml += `<div>Daily: ${w.daily_bonus.claimed ? 'Claimed' : 'Avail'}</div>`; 1097 } 1098 1099 if (user.pool) { 1100 detailsHtml += `<div style="margin-top:4px;"><strong>Free Pool:</strong></div>`; 1101 detailsHtml += `<div>Available: ${user.pool.available_balance_sc || '0'}</div>`; 1102 const locked = 1103 user.pool.locked_total_sc || 1104 (user.pool.locked_buckets || []).reduce( 1105 (acc, b) => acc + Number(b.amount_sc), 1106 0 1107 ) || 1108 '0'; 1109 detailsHtml += `<div>Locked: ${locked}</div>`; 1110 } 1111 1112 detailsHtml += `</div>`; 1113 sidebarBalance.insertAdjacentHTML('beforeend', detailsHtml); 1114 } 1115 } 1116 1117 // Populate User ID section 1118 const userIdSection = document.getElementById('user-id-section'); 1119 const userIdValue = document.getElementById('user-id-value'); 1120 const copyUserIdBtn = document.getElementById('copy-user-id-btn'); 1121 1122 // Get UID from API response or directly from Firebase Auth 1123 const userUid = user.uid || window.SPECTRUE?.firebaseAuth?.currentUser?.uid; 1124 1125 if (userIdSection && userIdValue && userUid) { 1126 userIdValue.textContent = userUid; 1127 userIdValue.title = userUid; // Full ID on hover 1128 userIdSection.style.display = 'block'; 1129 1130 // Setup copy handler (remove old listener first to avoid duplicates) 1131 if (copyUserIdBtn) { 1132 const newBtn = copyUserIdBtn.cloneNode(true); 1133 copyUserIdBtn.parentNode.replaceChild(newBtn, copyUserIdBtn); 1134 newBtn.addEventListener('click', async () => { 1135 try { 1136 await navigator.clipboard.writeText(userUid); 1137 showToast(T('toast.id_copied') || 'ID copied'); 1138 } catch (e) { 1139 console.warn('Copy failed:', e); 1140 } 1141 }); 1142 } 1143 } else if (userIdSection) { 1144 userIdSection.style.display = 'none'; 1145 } 1146 1147 const html = ` 1148 <button id="user-menu-trigger" class="user-menu-trigger icon-button" aria-label="Menu" aria-expanded="false" aria-haspopup="true"> 1149 <svg class="icon"><use href="#menu"></use></svg> 1150 </button> 1151 <div id="user-menu-dropdown" class="user-menu-dropdown hidden"> 1152 <div class="menu-actions"> 1153 <a href="/profile.html" class="menu-item"> 1154 <img class="menu-avatar" src="${photoURL}" alt="${escapeHtml(email)}" /> ${T('nav.profile') || 'Profile'} 1155 </a> 1156 <a href="${getPricingUrl()}" class="menu-item"> 1157 <svg class="icon"><use href="#plus"></use></svg> ${T('credits.top_up') || 'Top Up'} 1158 </a> 1159 <button id="menu-history" class="menu-item"> 1160 <svg class="icon"><use href="#clock"></use></svg> ${T('sidebar.history_title') || 'History'} 1161 </button> 1162 <a href="/api.html" class="menu-item"> 1163 <svg class="icon"><use href="#settings"></use></svg> API Dashboard 1164 </a> 1165 </div> 1166 <div class="menu-settings"> 1167 <button id="menu-theme-toggle" class="menu-item"> 1168 <svg class="icon"><use href="#${isDark ? 'sun' : 'moon'}"></use></svg>
1169 <span id="theme-text">${isDark ? T('theme.light') || 'Light Mode' : T('theme.dark') || 'Dark Mode'}</span> 1170 </button> 1171 </div> 1172 <div class="menu-footer"> 1173 <button id="menu-logout" class="menu-item danger"> 1174 <svg class="icon"><use href="#logout"></use></svg> ${T('sidebar.logout_text') || 'Log Out'} 1175 </button> 1176 </div> 1177 </div> 1178 `; 1179 1180 container.innerHTML = html; 1181 1182 // Bind events immediately after rendering 1183 const trigger = document.getElementById('user-menu-trigger'); 1184 if (trigger) { 1185 trigger.onclick = (e) => { 1186 e.stopPropagation(); 1187 toggleUserMenu(); 1188 }; 1189 } 1190 1191 // M140: Presentation Mode Selection in Menu 1192 const settingsContainer = container.querySelector('.menu-settings'); 1193 if (settingsContainer) { 1194 const currentMode = reportPresentationModeContext.getMode(); 1195 1196 const modeToggleContainer = document.createElement('div'); 1197 modeToggleContainer.className = 'menu-item'; 1198 modeToggleContainer.style.flexDirection = 'column'; 1199 modeToggleContainer.style.alignItems = 'stretch'; 1200 modeToggleContainer.style.cursor = 'default'; 1201 modeToggleContainer.style.background = 'transparent'; 1202 modeToggleContainer.style.padding = '8px'; 1203 modeToggleContainer.style.gap = '8px'; 1204 1205 const label = document.createElement('div'); 1206 label.style.display = 'flex'; 1207 label.style.alignItems = 'center'; 1208 label.style.gap = '10px'; 1209 label.innerHTML = `<svg class="icon"><use href="#eye"></use></svg> <span>${escapeHtml(T('menu.presentation_mode') || 'View Mode')}</span>`; 1210 1211 const toggleGroup = document.createElement('div'); 1212 toggleGroup.className = 'presentation-mode-toggle'; 1213 toggleGroup.style.display = 'flex'; 1214 toggleGroup.style.width = '100%'; 1215 // Use slightly smaller font/padding for menu context if needed, but class should handle it 1216 1217 const defaultBtn = document.createElement('button'); 1218 defaultBtn.type = 'button'; 1219 defaultBtn.textContent = T('report.mode_default') || 'Default'; 1220 defaultBtn.style.flex = '1'; 1221 defaultBtn.style.textAlign = 'center'; 1222 defaultBtn.style.justifyContent = 'center'; 1223 1224 if (currentMode !== 'expert') defaultBtn.className = 'is-active'; 1225 defaultBtn.onclick = async (e) => { 1226 e.stopPropagation(); 1227 reportPresentationModeContext.setMode('default'); 1228 const { saveUserSettings } = await import('./api.js?v=M42h_fix17'); 1229 saveUserSettings({ presentation_mode: 'default' }); 1230 }; 1231 1232 const expertBtn = document.createElement('button'); 1233 expertBtn.type = 'button'; 1234 expertBtn.textContent = T('report.mode_expert') || 'Expert'; 1235 expertBtn.style.flex = '1'; 1236 expertBtn.style.textAlign = 'center'; 1237 expertBtn.style.justifyContent = 'center'; 1238 1239 if (currentMode === 'expert') expertBtn.className = 'is-active'; 1240 expertBtn.onclick = async (e) => { 1241 e.stopPropagation(); 1242 reportPresentationModeContext.setMode('expert'); 1243 const { saveUserSettings } = await import('./api.js?v=M42h_fix17'); 1244 saveUserSettings({ presentation_mode: 'expert' }); 1245 }; 1246 toggleGroup.appendChild(defaultBtn); 1247 toggleGroup.appendChild(expertBtn); 1248 1249 modeToggleContainer.appendChild(label); 1250 modeToggleContainer.appendChild(toggleGroup); 1251 settingsContainer.appendChild(modeToggleContainer); 1252 } 1253 1254 window.dispatchEvent(new Event('spectrue:header-updated')); 1255} 1256 1257function toggleUserMenu() { 1258 const dropdown = document.getElementById('user-menu-dropdown'); 1259 const trigger = document.getElementById('user-menu-trigger'); 1260 if (!dropdown || !trigger) return; 1261 1262 const isHidden = dropdown.classList.contains('hidden'); 1263 if (isHidden) { 1264 dropdown.classList.remove('hidden'); 1265 trigger.setAttribute('aria-expanded', 'true'); 1266 // Close on click outside 1267 document.addEventListener('click', closeUserMenuOnClickOutside); 1268 } else { 1269 dropdown.classList.add('hidden'); 1270 trigger.setAttribute('aria-expanded', 'false'); 1271 document.removeEventListener('click', closeUserMenuOnClickOutside); 1272 } 1273} 1274 1275function toggleHistoryModal() { 1276 let modal = document.getElementById('history-modal'); 1277 if (!modal) { 1278 modal = document.createElement('div'); 1279 modal.id = 'history-modal'; 1280 modal.className = 'modal-overlay'; 1281 modal.innerHTML = ` 1282 <div class="modal-content"> 1283 <div class="modal-header"> 1284 <h3>${T('sidebar.history_title') || 'History'}</h3> 1285 <button class="icon-button close-modal"><svg class="icon"><use href="#plus" style="transform:rotate(45deg)"></use></svg></button> 1286 </div> 1287 <div id="history-list-container" class="modal-body"> 1288 <!-- History items injected here --> 1289 </div> 1290 </div> 1291 `; 1292 document.body.appendChild(modal); 1293 1294 modal.querySelector('.close-modal').onclick = () => 1295 (modal.style.display = 'none'); 1296 modal.onclick = (e) => { 1297 if (e.target === modal) modal.style.display = 'none'; 1298 }; 1299 } 1300 1301 modal.style.display = 'flex'; 1302
1303 // Trigger load history logic (imported from handlers in app.js usually, but we can dispatch event or expect caller to handle) 1304 // Better to dispatch an event that app.js or handlers.js listens to 1305 window.dispatchEvent(new CustomEvent('spectrue:load-history')); 1306} 1307 1308function initDeepToggleListener() { 1309 if (window._deepToggleInitializedFlag) return; 1310 window._deepToggleInitializedFlag = true; 1311 1312 const btn = document.getElementById('sidebar-deep-toggle'); 1313 const checkbox = document.getElementById('deep-analysis-toggle'); 1314 const cardHeader = document.querySelector('.sidebar-card .card-header'); 1315 const cardIcon = document.querySelector('.sidebar-card .card-icon'); 1316 1317 if (!checkbox) return; 1318 1319 const updateState = () => { 1320 const isActive = checkbox.checked; 1321 if (btn) { 1322 if (isActive) btn.classList.add('active'); 1323 else btn.classList.remove('active'); 1324 } 1325 if (cardIcon) { 1326 if (isActive) cardIcon.classList.add('active'); 1327 else cardIcon.classList.remove('active'); 1328 } 1329 }; 1330 1331 // Sync initial state 1332 updateState(); 1333 1334 const toggle = (e) => { 1335 e.preventDefault(); 1336 e.stopPropagation(); 1337 e.stopImmediatePropagation(); 1338 1339 checkbox.checked = !checkbox.checked; 1340 updateState(); 1341 checkbox.dispatchEvent(new Event('change')); 1342 }; 1343 if (btn) btn.onclick = toggle; 1344 if (cardHeader) cardHeader.onclick = toggle; 1345 1346 // Listen for external changes to the checkbox to keep buttons in sync 1347 checkbox.addEventListener('change', updateState); 1348} 1349 1350function closeUserMenuOnClickOutside(event) { 1351 const dropdown = document.getElementById('user-menu-dropdown'); 1352 const trigger = document.getElementById('user-menu-trigger'); 1353 if (!dropdown || !trigger) return; 1354 1355 if (!dropdown.contains(event.target) && !trigger.contains(event.target)) { 1356 dropdown.classList.add('hidden'); 1357 trigger.setAttribute('aria-expanded', 'false'); 1358 document.removeEventListener('click', closeUserMenuOnClickOutside); 1359 } 1360} 1361 1362// --- Global Tooltip Logic --- 1363// Now imported from helpers/ui_components.js 1364
1365// M140: Subscribe to presentation mode changes to re-render UI if needed 1366reportPresentationModeContext.subscribe(() => { 1367 // Re-render menu to update active state 1368 const user = window.SPECTRUE?.firebaseAuth?.currentUser; 1369 if (user) { 1370 import('./api.js?v=M42h_fix17').then(({ meCredits }) => { 1371 meCredits().then((r) => { 1372 if (r.ok) renderUserMenu(r.data); 1373 }); 1374 }); 1375 } else { 1376 renderUserMenu(null); 1377 } 1378 1379 // Re-render result box 1380 if (cachedResultPayload) { 1381 if (cachedResultPayload.status === 'done' && cachedResultPayload.result) { 1382 renderCheckResult(cachedResultPayload); 1383 } 1384 } 1385});
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.