PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/components/RecordModal.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:27:13 UTC 29,470 bytes, 726 lines download raw bytes

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.