1 2import { useEffect, useRef, useState } from 'react'; 3import { html } from '../html.js?v=3.8.36'; 4import { X, ExternalLink, ArrowLeft, ArrowRight, Quote, CheckCircle, Link, Share2, Loader2, AlertTriangle, RefreshCw, Sparkles } from 'lucide-react'; 5import { fetchRecordDetails, fetchEntitiesData, areEntitiesLoaded, calculateEntityConnectionStrength, getEntitiesByRecord } from '../services/archiveService.js?v=3.8.36'; 6import { ThreadModal } from './ThreadModal.js?v=3.8.36'; 7import { splitUrlsForLinkify } from '../utils/linkify.js?v=3.8.36'; 8import { sanitizeHref } from '../utils/sanitizeHref.js?v=3.8.36'; 9import { recordNeedsReview } from '../utils/needsReview.js?v=3.8.36'; 10import { canonicalRecordUrl, shareRecordUrl } from '../utils/recordDeepLink.js?v=3.8.36'; 11import { acquireBodyScrollLock } from '../services/bodyScrollLock.js?v=3.8.36'; 12import { requestSemanticNeighbors, selectSemanticRecords } from '../services/semanticRecall.js?v=3.8.36'; 13 14const linkifyText = (text) => { 15 const parts = splitUrlsForLinkify(text); 16 if (parts === null) return null; 17 return parts.map((part, i) => { 18 if (part.type === 'url') { 19 return html`<a key=${i} href=${part.value} target="_blank" rel="noopener noreferrer" className="archive-inline-link">${part.value}</a>`; 20 } 21 return part.value; 22 }); 23}; 24 25const TagGroup = ({ title, tags, onClick }) => { 26 if (!tags || tags.length === 0) return null; 27 return html` 28 <section className="archive-record-tag-group"> 29 <h4>${title}</h4> 30 <div className="archive-record-tag-list"> 31 ${tags.map(tag => onClick ? html` 32 <button type="button" key=${tag} onClick=${() => onClick(tag)}>${tag}</button> 33 ` : html`<span key=${tag}>${tag}</span>`)} 34 </div> 35 </section> 36 `; 37}; 38 39const blocksRecordNavigation = (target) => target instanceof Element && Boolean(target.closest( 40 'input, select, textarea, iframe, [contenteditable="true"], .archive-record-source' 41)); 42 43const getSourceName = (record) => { 44 if (record?.pub) return record.pub; 45 try { 46 return new URL(record?.url).hostname.replace(/^www\./, ''); 47 } catch { 48 return 'original source'; 49 } 50}; 51 52const hasPublicSourceUrl = (record) => { 53 try { 54 const sourceUrl = new URL(record?.url); 55 return sourceUrl.protocol === 'http:' || sourceUrl.protocol === 'https:'; 56 } catch { 57 return false; 58 } 59}; 60 61const RecordModal = ({ record, allRecords, isOpen, onClose, onNext, onPrev, onSelectRecord, onSelectEntity, onFilterCategory, onFilterSearch, onReportProblem, nestedDialogOpen = false, hasPrev, hasNext, currentIndex, total }) => { 62 const [isClosing, setIsClosing] = useState(false); 63 const [showToast, setShowToast] = useState(false); 64 const [toastMessage, setToastMessage] = useState(''); 65 const [relatedWorks, setRelatedWorks] = useState([]); 66 const [recordEntities, setRecordEntities] = useState([]); 67 const [semanticWorks, setSemanticWorks] = useState([]); 68 const [semanticStatus, setSemanticStatus] = useState('idle'); 69 const [semanticRequestKey, setSemanticRequestKey] = useState(0); 70 71 // State for lazy-loaded details 72 const [fullRecord, setFullRecord] = useState(null); 73 const [loadingDetails, setLoadingDetails] = useState(false); 74 const [detailsError, setDetailsError] = useState(''); 75 const [detailsRequestKey, setDetailsRequestKey] = useState(0); 76 const dialogRef = useRef(null); 77 const closeButtonRef = useRef(null); 78 const openerRef = useRef(null); 79 const backgroundStateRef = useRef([]); 80 const hasRecord = Boolean(record); 81 const currentFullRecord = fullRecord?.id === record?.id ? fullRecord : null; 82 83 useEffect(() => { 84 if (!isOpen || !hasRecord) return undefined; 85 return acquireBodyScrollLock(); 86 }, [isOpen, hasRecord]); 87 88 // Fetch details when modal opens 89 useEffect(() => {
90 let cancelled = false; 91 92 if (!isOpen || !record) { 93 setFullRecord(null); 94 setDetailsError(''); 95 return undefined; 96 } 97 98 setIsClosing(false); 99 setDetailsError(''); 100 101 if (record.summary && record.url) { 102 setFullRecord(record); 103 setLoadingDetails(false); 104 return () => { cancelled = true; }; 105 } 106 107 setFullRecord(null); 108 setLoadingDetails(true); 109 fetchRecordDetails(record.id) 110 .then(details => { 111 if (cancelled) return; 112 if (!details) { 113 setFullRecord({ 114 ...record, 115 summary: record.summaryPreview || record.title, 116 quote: '', 117 concepts: [], 118 tags: [], 119 url: '#', 120 author: record.author || 'Jay Rosen', 121 relatedIds: [] 122 }); 123 return; 124 } 125 setFullRecord({ ...record, ...details }); 126 }) 127 .catch(() => { 128 if (!cancelled) setDetailsError('The full record could not be loaded. The archive summary is still available.'); 129 }) 130 .finally(() => { 131 if (!cancelled) setLoadingDetails(false); 132 }); 133 134 return () => { cancelled = true; }; 135 }, [isOpen, record?.id, detailsRequestKey]); 136 137 // Find related works based on shared entities 138 useEffect(() => { 139 if (!currentFullRecord || !allRecords) return; 140 141 const findRelated = async () => { 142 setRecordEntities([]); 143 // Ensure entity data is loaded 144 if (!areEntitiesLoaded()) { 145 await fetchEntitiesData(); 146 } 147 148 setRecordEntities( 149 getEntitiesByRecord(currentFullRecord.id) 150 .sort((a, b) => (b.prominence || 0) - (a.prominence || 0)) 151 .slice(0, 8) 152 ); 153 154 // Calculate entity-based connections to all other article records 155 const connections = []; 156 const candidateRecords = allRecords.filter(r => 157 r.id !== currentFullRecord.id && r.type !== 'social' 158 ); 159 160 for (const candidate of candidateRecords) { 161 const result = calculateEntityConnectionStrength(currentFullRecord.id, candidate.id); 162 if (result.strength > 0) { 163 connections.push({ 164 ...candidate, 165 connectionStrength: result.strength, 166 prominenceScore: result.prominenceScore, 167 sharedEntities: result.sharedEntities 168 }); 169 } 170 } 171 172 // Sort by connection strength, take top 6 173 connections.sort((a, b) => b.connectionStrength - a.connectionStrength || b.prominenceScore - a.prominenceScore); 174 let related = connections.slice(0, 6); 175 176 // Fallback: if no entity connections, use category matching 177 if (related.length === 0) { 178 related = allRecords.filter(r => 179 r.id !== currentFullRecord.id && 180 r.type !== 'social' && 181 (r.categories || []).some(c => (currentFullRecord.categories || []).includes(c)) 182 ).slice(0, 4); 183 } 184 185 setRelatedWorks(related); 186 }; 187 188 findRelated(); 189 }, [currentFullRecord, allRecords]); 190 191 useEffect(() => { 192 const controller = new AbortController(); 193 194 if ( 195 !isOpen || 196 !record?.id || 197 record?.type !== 'article' || 198 !Array.isArray(allRecords) 199 ) { 200 setSemanticWorks([]); 201 setSemanticStatus('idle'); 202 return () => controller.abort(); 203 } 204 205 setSemanticWorks([]); 206 setSemanticStatus('loading'); 207 requestSemanticNeighbors(record.id, allRecords, { signal: controller.signal }) 208 .then(neighbors => { 209 if (controller.signal.aborted) return; 210 const matches = selectSemanticRecords(neighbors, allRecords); 211 setSemanticWorks(matches); 212 setSemanticStatus(matches.length > 0 ? 'ready' : 'empty'); 213 }) 214 .catch(error => { 215 if (controller.signal.aborted || error?.name === 'AbortError') return; 216 setSemanticWorks([]); 217 setSemanticStatus('error'); 218 }); 219 220 return () => controller.abort(); 221 }, [isOpen, record?.id, allRecords, semanticRequestKey]); 222 223 useEffect(() => { 224 const handleKeyDown = (e) => { 225 if (!isOpen || nestedDialogOpen) return; 226 const focusInsideRecord = dialogRef.current?.contains(document.activeElement); 227 if (!focusInsideRecord && document.activeElement !== document.body) return; 228 if (e.key === 'Escape') { 229 handleClose(); 230 return; 231 } 232 if (!blocksRecordNavigation(e.target) && e.key === 'ArrowLeft' && hasPrev) { 233 e.preventDefault(); 234 onPrev(); 235 } 236 if (!blocksRecordNavigation(e.target) && e.key === 'ArrowRight' && hasNext) { 237 e.preventDefault(); 238 onNext(); 239 } 240 if (e.key === 'Tab' && dialogRef.current) { 241 const focusable = Array.from(dialogRef.current.querySelectorAll(
242 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' 243 )).filter((element) => element.offsetParent !== null); 244 if (focusable.length === 0) { 245 e.preventDefault(); 246 dialogRef.current.focus(); 247 return; 248 } 249 const first = focusable[0]; 250 const last = focusable[focusable.length - 1]; 251 if (e.shiftKey && document.activeElement === first) { 252 e.preventDefault(); 253 last.focus(); 254 } else if (!e.shiftKey && document.activeElement === last) { 255 e.preventDefault(); 256 first.focus(); 257 } 258 } 259 }; 260 window.addEventListener('keydown', handleKeyDown); 261 return () => window.removeEventListener('keydown', handleKeyDown); 262 }, [isOpen, nestedDialogOpen, hasPrev, hasNext, onPrev, onNext]); 263 264 // The dialog lives beside the archive shell in both standard and desktop 265 // routes. Preserve each sibling's prior state while the record is open so 266 // pointer, keyboard, and assistive-technology navigation cannot escape into 267 // the obscured surface. Active modal siblings (such as a report opened before 268 // record data finishes loading) must stay available above the record. 269 useEffect(() => { 270 if (!isOpen || !hasRecord) return undefined; 271 272 const dialog = dialogRef.current?.closest('.archive-record-dialog'); 273 const backgroundElements = dialog?.parentElement 274 ? Array.from(dialog.parentElement.children).filter(element => ( 275 element !== dialog 276 && !element.matches('[role="dialog"][aria-modal="true"]') 277 )) 278 : []; 279 280 backgroundStateRef.current = backgroundElements.map(element => ({ 281 element, 282 ariaHidden: element.getAttribute('aria-hidden'), 283 inert: element.inert 284 })); 285 286 backgroundStateRef.current.forEach(({ element }) => { 287 element.inert = true; 288 element.setAttribute('aria-hidden', 'true'); 289 }); 290 291 return () => { 292 backgroundStateRef.current.forEach(({ element, ariaHidden, inert }) => { 293 element.inert = inert; 294 if (ariaHidden === null) element.removeAttribute('aria-hidden'); 295 else element.setAttribute('aria-hidden', ariaHidden); 296 }); 297 backgroundStateRef.current = []; 298 }; 299 }, [isOpen, hasRecord]); 300 301 useEffect(() => { 302 if (!isOpen) { 303 // Browser Back can dismiss the record while its report stays open above 304 // it. Leave focus and the saved opener alone until that topmost dialog 305 // closes; focusing through the report would break its modal boundary. 306 if (nestedDialogOpen) return undefined; 307 308 const opener = openerRef.current; 309 const openerWindow = opener?.closest('.desktop-window'); 310 // A history traversal can close the record and activate a different 311 // desktop window in the same render. Focusing an opener in the now- 312 // background window would activate it again and undo Back/Forward. 313 // Standard views have no desktop-window ancestor and retain the normal 314 // exact-opener return; explicit closes in the active desktop window do too. 315 if ( 316 opener?.isConnected 317 && (!openerWindow || openerWindow.classList.contains('is-active')) 318 ) { 319 opener.focus(); 320 } 321 openerRef.current = null; 322 return undefined; 323 } 324 325 if (!hasRecord || nestedDialogOpen) return undefined; 326 if (openerRef.current) return undefined; 327 328 openerRef.current = document.activeElement instanceof HTMLElement 329 ? document.activeElement 330 : null; 331 const frame = requestAnimationFrame(() => closeButtonRef.current?.focus()); 332 return () => cancelAnimationFrame(frame); 333 }, [isOpen, hasRecord, nestedDialogOpen]); 334 335 const completeClose = (afterClose = null) => { 336 onClose(); 337 setIsClosing(false); 338 afterClose?.(); 339 }; 340 341 const beginClose = (afterClose = null) => { 342 const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; 343 if (reduceMotion) { 344 completeClose(afterClose); 345 return; 346 } 347 setIsClosing(true); 348 setTimeout(() => completeClose(afterClose), 180); 349 }; 350 351 const handleClose = () => beginClose(); 352 353 const leaveRecordFor = (action, value) => { 354 // This action intentionally activates a different archive surface. Do not 355 // return focus to the record opener in the now-background source window: 356 // DesktopShell correctly treats that focus as window activation and would 357 // otherwise undo the requested navigation after the close delay. 358 openerRef.current = null; 359 beginClose(() => action(value)); 360 }; 361 362 const showNotification = (msg) => { 363 setToastMessage(msg); 364 setShowToast(true); 365 setTimeout(() => setShowToast(false), 3000); 366 }; 367 368 const handleCopyCitation = () => { 369 if (!currentFullRecord) return; 370 const recordUrl = canonicalRecordUrl(window.location.href, currentFullRecord.id); 371 const citationAuthor = currentFullRecord.author || 'Jay Rosen'; 372 const citationYear = currentFullRecord.year || 'n.d.'; 373 const citationSource = currentFullRecord.pub ? ` ${currentFullRecord.pub}.` : ''; 374 const text = `${citationAuthor} (${citationYear}). "${currentFullRecord.title}".${citationSource} Retrieved from ${recordUrl}`; 375 navigator.clipboard.writeText(text).then(() => showNotification("Citation copied to clipboard")); 376 }; 377 378 // Social share previews belong to the original platform, whose source URL is 379 // loaded with the detail record rather than the lightweight archive card. 380 const shareRecord = record?.type === 'social' 381 ? currentFullRecord || (detailsError ? record : null) 382 : record; 383 const sharePending = record?.type === 'social' && !shareRecord; 384 const shareUsesSource = shareRecord?.type === 'social' && hasPublicSourceUrl(shareRecord); 385 const shareLabel = sharePending 386 ? 'Loading source post link' 387 : shareUsesSource 388 ? 'Copy source post link' 389 : 'Copy canonical record link'; 390 const citationLabel = currentFullRecord 391 ? 'Copy citation' 392 : detailsError 393 ? 'Citation unavailable without full details' 394 : 'Loading citation details'; 395 396 const handleShare = () => { 397 if (!shareRecord) return; 398 const url = shareRecordUrl(window.location.href, shareRecord); 399 navigator.clipboard.writeText(url).then(() => showNotification("Link copied to clipboard")); 400 }; 401 402 if (!isOpen || !record) return null; 403 404 // Use fullRecord for display (with details), fallback to record for basic info 405 const displayRecord = currentFullRecord || record; 406 const isVideo = (displayRecord.url || '').includes('youtube') || (displayRecord.url || '').includes('vimeo'); 407 const youtubeId = (displayRecord.url || '').match(/^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/)?.[2]; 408 const hasSource = Boolean(displayRecord.url && sanitizeHref(displayRecord.url) !== '#'); 409 const sourceName = getSourceName(displayRecord); 410 const contentType = isVideo 411 ? 'Video' 412 : displayRecord.type === 'social' 413 ? (displayRecord.pub || 'Social media') 414 : displayRecord.type === 'Dissertation' 415 ? 'Dissertation' 416 : displayRecord.id?.startsWith('THREAD-') 417 ? 'Thread' 418 : 'Article'; 419 const summary = displayRecord.summary || displayRecord.summaryPreview || ''; 420 const navigationIsFiltered = Array.isArray(allRecords) && total < allRecords.length; 421 const positionLabel = currentIndex >= 0 && total > 0 ? `${currentIndex + 1} of ${total}` : 'Record view'; 422 423 return html` 424 <div className="archive-record-dialog" role="dialog" aria-modal="true" aria-labelledby="record-modal-title"> 425 <div 426 className=${`archive-record-toast ${showToast ? 'is-visible' : ''}`} 427 role="status"
428 aria-live="polite" 429 > 430 <${CheckCircle} aria-hidden="true" /> 431 <span>${toastMessage}</span> 432 </div> 433 434 <div 435 className=${`archive-record-dialog__backdrop ${isClosing ? 'is-closing' : ''}`} 436 onClick=${handleClose} 437 aria-hidden="true" 438 ></div> 439 440 <div className="archive-record-dialog__positioner"> 441 <article 442 ref=${dialogRef} 443 tabIndex="-1" 444 className=${`archive-record-sheet ${isClosing ? 'is-closing' : ''}`} 445 > 446 <header className="archive-record-utility"> 447 <div className="archive-record-utility__identity"> 448 ${displayRecord.date && html`<time dateTime=${displayRecord.date}>${displayRecord.date}</time>`} 449 ${displayRecord.date && html`<span aria-hidden="true">â¢</span>`} 450 <span>${contentType}</span> 451 </div> 452 <div className="archive-record-utility__actions"> 453 <button 454 type="button" 455 onClick=${handleShare} 456 disabled=${sharePending} 457 className="archive-record-utility__action" 458 title=${shareLabel} 459 aria-label=${shareLabel} 460 > 461 <${Share2} aria-hidden="true" /> 462 <span>Share link</span> 463 </button> 464 <button 465 type="button" 466 onClick=${handleCopyCitation} 467 disabled=${!currentFullRecord} 468 className="archive-record-utility__action" 469 title=${citationLabel} 470 aria-label=${citationLabel} 471 > 472 <${Quote} aria-hidden="true" /> 473 <span>Copy citation</span> 474 </button> 475 <button 476 ref=${closeButtonRef} 477 type="button" 478 onClick=${handleClose} 479 className="archive-record-utility__action archive-record-utility__close" 480 title="Close record" 481 aria-label="Close record" 482 > 483 <${X} aria-hidden="true" /> 484 </button> 485 </div> 486 </header> 487 488 <div className="archive-record-document"> 489 ${loadingDetails && html` 490 <div className="archive-record-loading" role="status" aria-live="polite"> 491 <${Loader2} className="archive-record-loading__icon" aria-hidden="true" /> 492 <span>Loading the full recordâ¦</span> 493 </div> 494 `} 495 496 ${detailsError && html` 497 <div className="archive-record-error" role="alert"> 498 <${AlertTriangle} aria-hidden="true" /> 499 <div> 500 <strong>Some record details are unavailable.</strong> 501 <p>${detailsError}</p> 502 </div> 503 <button type="button" onClick=${() => setDetailsRequestKey(key => key + 1)}> 504 <${RefreshCw} aria-hidden="true" /> Retry details 505 </button> 506 </div> 507 `} 508 509 <header className="archive-record-heading"> 510 <h2 id="record-modal-title">${displayRecord.title}</h2> 511 512 <!-- Read needsReview from the core record, not displayRecord: 513 details merge lets stale detail data overwrite a new flag. --> 514 ${recordNeedsReview(record) && html` 515 <span className="archive-record-review-state" title="Auto-submitted; pending a human review pass"> 516 Needs review 517 </span> 518 `} 519 520 <p className="archive-record-byline">By ${displayRecord.author || 'Jay Rosen'}</p> 521 522 ${hasSource ? html` 523 <a 524 href=${sanitizeHref(displayRecord.url)} 525 target="_blank" 526 rel="noopener noreferrer" 527 className="archive-record-source archive-action archive-action--primary" 528 > 529 Read on ${sourceName} <${ExternalLink} aria-hidden="true" /> 530 </a> 531 ` : !loadingDetails && !detailsError ? html` 532 <p className="archive-record-source-status"> 533 <${AlertTriangle} aria-hidden="true" /> Source unavailable in this archive 534 </p> 535 ` : null} 536 537 <div className="archive-record-provenance">
538 <span>Archive ID: ${displayRecord.id}</span> 539 ${onReportProblem && html` 540 <button type="button" onClick=${() => onReportProblem(displayRecord.id)}> 541 Report a problem with this record 542 </button> 543 `} 544 </div> 545 </header> 546 547 ${youtubeId && html` 548 <div className="archive-record-media"> 549 <iframe 550 src=${`https://www.youtube-nocookie.com/embed/${youtubeId}`} 551 title=${`Video for ${displayRecord.title}`} 552 allowFullScreen=${true} 553 ></iframe> 554 </div> 555 `} 556 557 ${(() => { 558 // Show quote block only if it adds information beyond the title 559 if (youtubeId || !displayRecord.quote) return null; 560 const quoteText = displayRecord.quote.trim(); 561 const titleText = (displayRecord.title || '').replace(/\.\.\.$/,'').trim(); 562 // Skip if quote is effectively the same as the title 563 if (titleText.length > 10 && (quoteText === titleText || quoteText.startsWith(titleText) || titleText.startsWith(quoteText.substring(0, titleText.length)))) return null; 564 return html` 565 <blockquote className="archive-record-quotation"> 566 <${Quote} aria-hidden="true" /> 567 <p>â${quoteText}â</p> 568 </blockquote> 569 `; 570 })()} 571 572 ${displayRecord.id?.startsWith('THREAD-') && displayRecord.thread_data ? html` 573 <${ThreadModal} record=${displayRecord} /> 574 ` : summary ? html` 575 <section className="archive-record-summary" aria-labelledby="record-summary-title"> 576 <h3 id="record-summary-title">Summary</h3> 577 <p>${linkifyText(summary)}</p> 578 </section> 579 ` : html` 580 <p className="archive-record-incomplete">No summary is available for this record.</p> 581 `} 582 583 ${onSelectEntity && recordEntities.length > 0 && html` 584 <section className="archive-record-metadata archive-record-entities" aria-labelledby="record-entities-title"> 585 <h3 id="record-entities-title">People and ideas in this record</h3> 586 <p>Continue through the archive's canonical relationship index.</p> 587 <div className="archive-record-entity-list"> 588 ${recordEntities.map(entity => html` 589 <button 590 type="button" 591 key=${entity.id} 592 onClick=${() => leaveRecordFor(onSelectEntity, entity.id)} 593 aria-label=${`Explore ${entity.name} in People and ideas`} 594 > 595 <strong>${entity.name}</strong> 596 <span>${entity.type}</span> 597 </button> 598 `)} 599 </div> 600 </section> 601 `} 602 603 ${relatedWorks.length > 0 && html` 604 <section className="archive-record-related" aria-labelledby="record-related-title"> 605 <h3 id="record-related-title"><${Link} aria-hidden="true" /> Related records</h3> 606 <div className="archive-record-related__grid"> 607 ${relatedWorks.map(rel => html` 608 <button type="button" key=${rel.id} onClick=${() => onSelectRecord(rel.id)}> 609 <span className="archive-record-related__utility"> 610 <span>${rel.date}</span> 611 ${rel.connectionStrength > 0 && html` 612 <span>${rel.connectionStrength} shared ${rel.connectionStrength === 1 ? 'entity' : 'entities'}</span> 613 `} 614 </span> 615 <strong>${rel.title}</strong> 616 <span>${rel.pub}</span> 617 </button> 618 `)} 619 </div> 620 </section> 621 `} 622 623 ${semanticStatus !== 'idle' && semanticStatus !== 'empty' && html` 624 <details key=${record.id} className="archive-record-semantic"> 625 <summary>
626 <span className="archive-record-semantic__title"> 627 <${Sparkles} aria-hidden="true" /> Similar in theme 628 </span> 629 <span className="archive-record-semantic__count"> 630 ${semanticStatus === 'loading' 631 ? 'Finding matchesâ¦' 632 : semanticStatus === 'ready' 633 ? `${semanticWorks.length} ${semanticWorks.length === 1 ? 'match' : 'matches'}` 634 : 'Unavailable'} 635 </span> 636 </summary> 637 638 <div className="archive-record-semantic__body"> 639 ${semanticStatus === 'loading' && html` 640 <p className="archive-record-semantic__status" role="status"> 641 Comparing this record with the archiveâs thematic index⦠642 </p> 643 `} 644 645 ${semanticStatus === 'error' && html` 646 <div className="archive-record-semantic__status" role="status"> 647 <p>The thematic index could not be loaded.</p> 648 <button 649 type="button" 650 onClick=${() => setSemanticRequestKey(key => key + 1)} 651 > 652 <${RefreshCw} aria-hidden="true" /> Retry thematic matches 653 </button> 654 </div> 655 `} 656 657 ${semanticStatus === 'ready' && html` 658 <p className="archive-record-semantic__note"> 659 Language-pattern matches that may not share tagged people or ideas. 660 </p> 661 <div className="archive-record-related__grid"> 662 ${semanticWorks.map(match => html` 663 <button 664 type="button" 665 key=${match.id} 666 onClick=${() => onSelectRecord(match.id)} 667 > 668 <span className="archive-record-related__utility"> 669 <span>${match.date || match.year || 'Undated'}</span> 670 <span className="archive-record-semantic__signal">Related meaning</span> 671 </span> 672 <strong>${match.title}</strong> 673 <span>${match.pub || match.author || 'Archive record'}</span> 674 </button> 675 `)} 676 </div> 677 `} 678 </div> 679 </details> 680 `} 681 682 <section className="archive-record-metadata" aria-labelledby="record-metadata-title"> 683 <h3 id="record-metadata-title">Archive metadata</h3> 684 <div className="archive-record-metadata__grid"> 685 <${TagGroup} title="Thematic categories" tags=${displayRecord.categories} onClick=${onFilterCategory ? (cat) => leaveRecordFor(onFilterCategory, cat) : undefined} /> 686 <${TagGroup} title="Tags" tags=${displayRecord.tags} onClick=${onFilterSearch ? (tag) => leaveRecordFor(onFilterSearch, tag) : undefined} /> 687 <${TagGroup} title="Key concepts" tags=${displayRecord.concepts} onClick=${onFilterSearch ? (concept) => leaveRecordFor(onFilterSearch, concept) : undefined} /> 688 ${displayRecord.era && html` 689 <section className="archive-record-tag-group"> 690 <h4>Era</h4> 691 <p>${displayRecord.era}</p> 692 </section> 693 `} 694 </div> 695 </section> 696 </div> 697 698 <footer className="archive-record-navigation"> 699 <button 700 type="button" 701 onClick=${onPrev} 702 disabled=${!hasPrev} 703 > 704 <${ArrowLeft} aria-hidden="true" /> Previous 705 </button> 706 <div> 707 <strong>${positionLabel}</strong> 708 ${navigationIsFiltered && html` 709 <span className="archive-record-navigation__context">Within current filtered results</span> 710 `} 711 </div> 712 <button 713 type="button" 714 onClick=${onNext} 715 disabled=${!hasNext} 716 > 717 Next <${ArrowRight} aria-hidden="true" /> 718 </button> 719 </footer> 720 </article> 721 </div> 722 </div> 723 `; 724}; 725 726export default RecordModal;
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.