1 2import { Component, Suspense, lazy, useEffect, useState, useMemo, useRef, useCallback } from 'react'; 3import { html } from './html.js?v=3.8.36'; 4import { Newspaper, SlidersHorizontal, LayoutGrid, Folder, BookOpen, BookMarked, Compass, HelpCircle, MoreHorizontal, AlertCircle, ChevronUp, BarChart3, Users, Info, Bug, Github, Search, XCircle } from 'lucide-react'; 5import { fetchCoreData, fetchRecordDetails, preloadDetails, loadSearchIndex } from './services/archiveService.js?v=3.8.36'; 6import { perfMark, perfMeasure } from './utils/perfMark.js?v=3.8.36'; 7import { withViewTransition } from './utils/viewTransition.js?v=3.8.36'; 8import { CONTENT_TYPE_OPTIONS, ITEMS_PER_PAGE, REPORT_CONFIG } from './constants.js?v=3.8.36'; 9import { ROUTES, getCurrentRoute, getDesktopAppIdFromUrl, getEntityIdFromUrl, navigateTo, navigateToDesktop, getRecordIdFromUrl, migrateLegacyUrl } from './services/router.js?v=3.8.36'; 10import { parseViewState, viewStateToUrl } from './services/viewState.js?v=3.8.36'; 11import { ABOUT_PRIVACY_HASH, getPrivacyDetailsHref, resolvePrivacyRoute } from './services/privacyRoute.js?v=3.8.36'; 12import { canonicalRecordUrl, setRecordParam } from './utils/recordDeepLink.js?v=3.8.36'; 13import { 14 BROKEN_RECORD_SKIP_MS, 15 DISSERTATION_YEAR_NOTE, 16 NOWHERE_RECORD_ID, 17 brokenRecordLine, 18 isDissertationYearQuery, 19 summarizeBrokenRecord, 20} from './utils/easterEggs.js?v=3.8.36'; 21import { readReportDeepLink } from './utils/reportDeepLink.js?v=3.8.36'; 22import { resolveSitePath } from './utils/pathResolver.js?v=3.8.36'; 23import { recordNeedsReview } from './utils/needsReview.js?v=3.8.36'; 24import { 25 buildSearchText, 26 matchesParsedSearchText, 27 normalizeForSearch, 28 parseSearchQuery, 29 searchLoadedIndexes, 30} from './utils/searchNormalize.js?v=3.8.36'; 31import { sortRecords } from './utils/recordSort.js?v=3.8.36'; 32import { 33 DEFAULT_SORT, 34 RELEVANCE_SORT, 35 buildSearchRanking, 36 orderByFusedRank, 37 sortForQueryChange, 38 sortForSemanticToggle, 39} from './utils/searchRanking.js?v=3.8.36'; 40import { 41 requestSemanticSearch, 42 terminateSemanticSearch, 43 warmupSemanticSearch, 44} from './services/semanticSearch.js?v=3.8.36'; 45import { deriveFacetsForRecords, intersectByRecordIds } from './services/queryComposition.js?v=3.8.36'; 46import { formatReleaseDate, loadReleaseMetadata } from './services/releaseMetadata.js?v=3.8.36'; 47import Sidebar from './components/Sidebar.js?v=3.8.36'; 48import WelcomeModal from './components/WelcomeModal.js?v=3.8.36'; 49import RecordView from './components/RecordView.js?v=3.8.36'; 50import FeaturedSection from './components/FeaturedSection.js?v=3.8.36'; 51import DissertationPage from './components/DissertationPage.js?v=3.8.36'; 52import ToolsModal from './components/ToolsModal.js?v=3.8.36'; 53import BugReportModal from './components/BugReportModal.js?v=3.8.36'; 54import WorkInProgressBanner from './components/WorkInProgressBanner.js?v=3.8.36'; 55import AnalyticsDashboard from './components/AnalyticsDashboard.js?v=3.8.36'; 56import EntityBrowser from './components/EntityBrowser.js?v=3.8.36'; 57import Timeline from './components/Timeline.js?v=3.8.36'; 58import AboutPage from './components/AboutPage.js?v=3.8.36'; 59import WikiPage from './components/WikiPage.js?v=3.8.36'; 60import StartHerePage from './components/StartHerePage.js?v=3.8.36'; 61import ArchiveResults from './components/ArchiveResults.js?v=3.8.36'; 62import EasterEggNote from './components/EasterEggNote.js?v=3.8.36'; 63import NowherePage from './components/NowherePage.js?v=3.8.36'; 64import SemanticSearchToggle, { semanticStatusMessage } from './components/SemanticSearchToggle.js?v=3.8.36'; 65 66const DesktopShell = lazy(() => import('./desktop/DesktopShell.js?v=3.8.36')); 67 68const NON_RECORD_ROUTES = new Set([ 69 ROUTES.analytics, 70 ROUTES.wiki, 71 ROUTES.desktop, 72 // The hidden route is one line of text. It never needs the core data. 73 ROUTES.nowhere, 74]); 75 76const DESKTOP_RECORD_APPS = new Set(['archive', 'folders', 'entities', 'start', 'findings']); 77const DESKTOP_DETAILS_PRELOAD_APPS = new Set(['archive', 'folders']); 78const DESKTOP_GUIDED_SHELL_DESTINATIONS = new Set([ 79 'archive', 80 'folders', 81 'entities', 82 'dissertation', 83 'analytics', 84]); 85 86const ROUTE_ENTRY_FOCUS_SELECTOR = '[data-route-entry-focus], #main-content'; 87const MAX_MINI_INDEX_RETRIES = 3; 88 89// RELEVANCE_SORT (imported from utils/searchRanking.js) is deliberately NOT in 90// RECORD_SORTS: that list is the site-tools contract, and every key in it must 91// have a comparator in recordSort.js. Relevance has no comparator because it 92// orders by fused search rank, so it is offered only while a search term is 93// active and is handled before sortRecords runs. 94// 95// Wait this long after the last keystroke before encoding a query. One encode
96// per typed word instead of one per letter. 97const SEMANTIC_QUERY_DEBOUNCE_MS = 350; 98 99// One frozen empty result, so a cleared query does not create a new object and 100// re-run the filter memo on every render. 101const EMPTY_SEMANTIC_HITS = Object.freeze({ query: '', ids: [] }); 102const getCurrentRouteWithPrivacyFallback = () => 103 resolvePrivacyRoute(getCurrentRoute(), window.location.hash); 104 105class DesktopRouteErrorBoundary extends Component { 106 constructor(props) { 107 super(props); 108 this.state = { failed: false }; 109 this.failureHeading = null; 110 this.focusFailureFrame = null; 111 } 112 113 static getDerivedStateFromError() { 114 return { failed: true }; 115 } 116 117 componentDidCatch(error) { 118 console.error('Archive desktop failed to load:', error); 119 this.focusFailureFrame = requestAnimationFrame(() => { 120 this.focusFailureFrame = null; 121 this.failureHeading?.focus({ preventScroll: true }); 122 }); 123 } 124 125 componentWillUnmount() { 126 if (this.focusFailureFrame !== null) cancelAnimationFrame(this.focusFailureFrame); 127 } 128 129 render() { 130 if (!this.state.failed) return this.props.children; 131 132 return html` 133 <main id="main-content" className="min-h-screen px-4 py-16" style=${{ backgroundColor: '#fdfbf7' }}> 134 <div className="mx-auto max-w-xl border-2 border-stone-800 bg-white p-8 shadow-lg"> 135 <p className="mb-2 font-body text-xs font-bold uppercase tracking-wider text-stone-500">Archive desktop unavailable</p> 136 <h1 137 ref=${(element) => { this.failureHeading = element; }} 138 tabIndex="-1" 139 className="font-display text-3xl font-bold text-stone-900 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2" 140 >The standard archive is still ready.</h1> 141 <p className="mt-4 font-body text-sm leading-relaxed text-stone-600"> 142 The optional desktop view could not load. No archive data or standard navigation is affected. 143 </p> 144 <button 145 type="button" 146 onClick=${this.props.onExit} 147 className="mt-6 inline-flex items-center justify-center bg-stone-900 px-5 py-3 font-display text-sm font-bold text-white focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2" 148 > 149 Open the standard archive 150 </button> 151 </div> 152 </main> 153 `; 154 } 155} 156 157// One source of truth for the empty filter state. The initial state and both 158// "clear filters" paths spread this, so adding a filter field can't leave a 159// reset path out of sync. 160const DEFAULT_FILTERS = { 161 search: '', 162 categories: [], 163 era: null, 164 year: null, 165 publication: [], 166 type: null, 167 includeReplies: false, 168 recordIds: null, 169}; 170 171const App = () => { 172 const [records, setRecords] = useState([]); 173 const [facets, setFacets] = useState({ categories: [], eras: [], publications: [] }); 174 const [autocompleteIndex, setAutocompleteIndex] = useState([]); 175 const [loading, setLoading] = useState(true); 176 const [error, setError] = useState(null); 177 const [sidebarOpen, setSidebarOpen] = useState(false); 178 const [currentRoute, setCurrentRoute] = useState(() => getCurrentRouteWithPrivacyFallback()); 179 const [desktopAppId, setDesktopAppId] = useState(() => getDesktopAppIdFromUrl()); 180 const [desktopOpenAppIds, setDesktopOpenAppIds] = useState([]); 181 const [currentPage, setCurrentPage] = useState(1); 182 const [sortBy, setSortBy] = useState('date-desc'); 183 // Opt-in semantic search (#279). Off on every visit: turning it on downloads 184 // a model, so the choice is never made for the reader by a stored setting. 185 const [semanticEnabled, setSemanticEnabled] = useState(false); 186 const [semanticStatus, setSemanticStatus] = useState('idle'); 187 const [semanticCoverage, setSemanticCoverage] = useState(null); 188 const [semanticHits, setSemanticHits] = useState(EMPTY_SEMANTIC_HITS); 189 const [isScrolled, setIsScrolled] = useState(false); 190 // Initialise from ?record= so a deep-linked load survives mount. The URL-sync 191 // effect below runs on mount with this value already set, so it preserves the 192 // param instead of deleting it; the [records] effect then validates the id 193 // once the archive data finishes loading (clearing it if no record matches). 194 const [selectedRecordId, setSelectedRecordId] = useState(() => getRecordIdFromUrl()); 195 const [selectedEntityId, setSelectedEntityId] = useState(() => getEntityIdFromUrl()); 196 const [toolsModalOpen, setToolsModalOpen] = useState(false); 197 const [bugReportOpen, setBugReportOpen] = useState(false); 198 const [bugReportIntent, setBugReportIntent] = useState('problem'); 199 const [bugReportInitialFields, setBugReportInitialFields] = useState({}); 200 const [showBackToTop, setShowBackToTop] = useState(false); 201 const [announcedResultCount, setAnnouncedResultCount] = useState(''); 202 const [currentHash, setCurrentHash] = useState(() => window.location.hash); 203 const [releaseMetadata, setReleaseMetadata] = useState(null); 204 const [updateAvailable, setUpdateAvailable] = useState( 205 () => Boolean(window.__jrdaUpdateReady), 206 ); 207 // Hidden extras (#754). Each one waits for a deliberate action, shows one 208 // dismissible line, and leaves reading, search, and navigation untouched. 209 const [brokenRecord, setBrokenRecord] = useState(null); 210 const [recordsSkipping, setRecordsSkipping] = useState(false); 211 const [yearNoteDismissed, setYearNoteDismissed] = useState(false); 212 213 const [filters, setFilters] = useState(() => ({ 214 ...DEFAULT_FILTERS, 215 ...parseViewState(window.location.href).filters, 216 })); 217 const desktopActiveNeedsRecords = currentRoute === ROUTES.desktop 218 && DESKTOP_RECORD_APPS.has(desktopAppId); 219 const desktopDisallowsRecordContext = currentRoute === ROUTES.desktop 220 && !DESKTOP_RECORD_APPS.has(desktopAppId); 221 const selectedRecordIsLoaded = useMemo( 222 () => Boolean(selectedRecordId) && records.some(({ id }) => id === selectedRecordId), 223 [records, selectedRecordId], 224 ); 225 const desktopRecordOverlayOpen = desktopActiveNeedsRecords && selectedRecordIsLoaded; 226 const desktopNeedsRecords = currentRoute === ROUTES.desktop 227 && ( 228 desktopActiveNeedsRecords 229 || desktopOpenAppIds.some((appId) => DESKTOP_RECORD_APPS.has(appId)) 230 ); 231 const desktopNeedsDetailsPreload = currentRoute === ROUTES.desktop 232 && ( 233 DESKTOP_DETAILS_PRELOAD_APPS.has(desktopAppId) 234 || desktopOpenAppIds.some((appId) => DESKTOP_DETAILS_PRELOAD_APPS.has(appId)) 235 ); 236 237 // Ref for scrolling to results 238 const resultsRef = useRef(null); 239 const recordsRef = useRef(null); 240 const filterTriggerRef = useRef(null); 241 const scopeStatusRef = useRef(null); 242 const scopeTokenRefs = useRef(new Map()); 243 const pendingScopeFocusKey = useRef(null); 244 const reportEntryHandled = useRef(false); 245 const previouslyRenderedRoute = useRef(currentRoute); 246 247 const openBugReport = useCallback((intent = 'problem', initialFields = {}) => { 248 setBugReportIntent(intent); 249 setBugReportInitialFields(initialFields); 250 setBugReportOpen(true); 251 }, []); 252 253 const handleRecordProblemReport = useCallback(() => { 254 // The selected record id already stays in the page URL captured with the 255 // report. Keep the required description empty so the reader must describe 256 // the actual problem instead of submitting an auto-filled prefix alone. 257 openBugReport('problem'); 258 }, [openBugReport]); 259 260 // Hash navigation replaces parts of the React tree without giving the 261 // browser a document-navigation focus reset. When the invoking control is 262 // unmounted, focus otherwise falls through to <body> and a keyboard or 263 // screen-reader user receives no indication that a new page appeared. 264 // 265 // Child views with a more precise destination (record dialogs, selected 266 // entities, Start here, desktop windows) claim focus in their own effects.
267 // Re-check at paint time and yield to any connected non-body target so this 268 // fallback cannot overwrite those richer focus contracts. 269 useEffect(() => { 270 const handleUpdateReady = () => setUpdateAvailable(true); 271 window.addEventListener('jrda:update-ready', handleUpdateReady); 272 return () => window.removeEventListener('jrda:update-ready', handleUpdateReady); 273 }, []); 274 275 useEffect(() => { 276 const previousRoute = previouslyRenderedRoute.current; 277 previouslyRenderedRoute.current = currentRoute; 278 if (previousRoute === currentRoute) return undefined; 279 280 const frame = requestAnimationFrame(() => { 281 const activeElement = document.activeElement; 282 if ( 283 activeElement instanceof HTMLElement 284 && activeElement !== document.body 285 && activeElement.isConnected 286 ) return; 287 288 const focusTarget = document.querySelector(ROUTE_ENTRY_FOCUS_SELECTOR); 289 if (!(focusTarget instanceof HTMLElement)) return; 290 focusTarget.focus({ preventScroll: true }); 291 }); 292 return () => cancelAnimationFrame(frame); 293 }, [currentRoute]); 294 295 // Privacy entry points have a more precise destination than the About page 296 // heading. The hashchange route sync makes this work both across routes and 297 // when the reader is already on About, while the post-render effect ensures 298 // the disclosure exists before it is scrolled into view and focused. 299 useEffect(() => { 300 if (currentRoute !== ROUTES.about || currentHash !== `#${ABOUT_PRIVACY_HASH}`) return undefined; 301 302 const frame = requestAnimationFrame(() => { 303 const privacyTarget = document.getElementById('privacy-and-browser-storage'); 304 if (!(privacyTarget instanceof HTMLElement)) return; 305 privacyTarget.scrollIntoView({ block: 'start' }); 306 privacyTarget.focus({ preventScroll: true }); 307 }); 308 return () => cancelAnimationFrame(frame); 309 }, [currentHash, currentRoute]); 310 311 useEffect(() => { 312 if (reportEntryHandled.current) return; 313 reportEntryHandled.current = true; 314 const entry = readReportDeepLink(window.location.href); 315 if (!entry.intent) return; 316 setBugReportIntent(entry.intent); 317 setBugReportInitialFields({}); 318 setBugReportOpen(true); 319 window.history.replaceState({}, '', entry.cleanHref); 320 }, []); 321 322 // Migrate legacy ?view= URLs on mount, then sync hash state 323 useEffect(() => { 324 migrateLegacyUrl(); 325 326 const syncRoute = (event) => { 327 const route = getCurrentRouteWithPrivacyFallback(); 328 setCurrentRoute(route); 329 setCurrentHash(window.location.hash); 330 setDesktopAppId(getDesktopAppIdFromUrl()); 331 setSelectedEntityId(getEntityIdFromUrl()); 332 333 // Desktop filters replace the current history entry as their readable 334 // query string changes. Back/Forward must therefore restore those 335 // historical query-backed filters alongside the active window. Ordinary 336 // hash navigation keeps the live in-memory filters (standard archive 337 // filters are not URL-synchronised), while transient analytics recordIds 338 // intentionally clear because they are not part of the URL contract. 339 if (event?.type === 'popstate') { 340 const historicalState = parseViewState(window.location.href); 341 setFilters({ 342 ...DEFAULT_FILTERS, 343 ...historicalState.filters, 344 }); 345 } 346 347 const recordId = getRecordIdFromUrl(); 348 if (recordId && records.length > 0) { 349 const target = records.find(r => r.id === recordId); 350 setSelectedRecordId(target ? recordId : null); 351 } else if (!recordId) { 352 setSelectedRecordId(null); 353 } 354 }; 355 356 syncRoute(); 357 window.addEventListener('hashchange', syncRoute); 358 window.addEventListener('popstate', syncRoute); 359 return () => { 360 window.removeEventListener('hashchange', syncRoute); 361 window.removeEventListener('popstate', syncRoute); 362 }; 363 }, [records]); 364 365 // Update URL when record selected (without changing route) 366 useEffect(() => { 367 if (desktopActiveNeedsRecords) return; 368 const url = new URL(window.location.href); 369 setRecordParam( 370 url.searchParams, 371 desktopDisallowsRecordContext ? null : selectedRecordId, 372 ); 373 if (desktopDisallowsRecordContext) { 374 url.searchParams.delete('entity'); 375 if (selectedRecordId !== null) setSelectedRecordId(null); 376 } 377 try { 378 window.history.replaceState({}, '', url); 379 } catch(e) { console.warn("History update blocked"); } 380 }, [selectedRecordId, desktopActiveNeedsRecords, desktopDisallowsRecordContext]); 381 382 // Desktop archive links preserve the canonical filter query plus the shared 383 // ?record= deep link. The hash only identifies the active shell app; no 384 // second desktop-only filter or record URL format is introduced. 385 useEffect(() => { 386 if (!desktopActiveNeedsRecords) return; 387 const nextUrl = viewStateToUrl({ 388 route: ROUTES.desktop,
389 routeParams: { 390 desktopAppId, 391 ...(desktopAppId === 'entities' && selectedEntityId ? { entityId: selectedEntityId } : {}), 392 }, 393 filters, 394 selectedRecord: selectedRecordId, 395 }, window.location.href); 396 try { 397 window.history.replaceState({}, '', nextUrl); 398 } catch { 399 console.warn('Desktop view-state update blocked'); 400 } 401 }, [desktopActiveNeedsRecords, desktopAppId, filters, selectedRecordId, selectedEntityId]); 402 403 useEffect(() => { 404 if (currentRoute !== ROUTES.entities) return; 405 const currentState = parseViewState(window.location.href); 406 const nextUrl = viewStateToUrl({ 407 route: ROUTES.entities, 408 routeParams: selectedEntityId ? { entityId: selectedEntityId } : {}, 409 filters: currentState.filters, 410 selectedRecord: selectedRecordId, 411 }, window.location.href); 412 try { 413 window.history.replaceState({}, '', nextUrl); 414 } catch { 415 console.warn('Entity view-state update blocked'); 416 } 417 }, [currentRoute, selectedEntityId, selectedRecordId]); 418 419 // Navigation helpers 420 const goTo = useCallback((route) => { 421 navigateTo(route); 422 }, []); 423 424 const goToDesktop = useCallback((appId = null) => { 425 navigateToDesktop(appId); 426 }, []); 427 428 const handleDesktopGuideNavigate = useCallback((destination) => { 429 if (destination === 'desktop') { 430 navigateToDesktop(); 431 } else if (DESKTOP_GUIDED_SHELL_DESTINATIONS.has(destination)) { 432 navigateToDesktop(destination); 433 } else { 434 navigateTo(destination); 435 } 436 }, []); 437 438 // Open, close, or switch the record modal with a View Transition cross-fade 439 // (#281). Used by every deliberate record selection -- grid card, entity 440 // browser, in-modal related links, and close -- so they animate the same way; 441 // browsers without the API just set state directly (see withViewTransition). 442 // Two paths stay plain on purpose: prev/next arrow paging (handleModalNav), 443 // where a cross-fade on every press would lag, and history-driven sync (back/ 444 // forward, deep links), which should not animate on navigation. 445 const selectRecord = useCallback((id) => { 446 withViewTransition(() => setSelectedRecordId(id)); 447 }, []); 448 449 // Tag click handlers (from RecordModal) â go to archive and filter 450 const handleFilterCategory = useCallback((cat) => { 451 setFilters(prev => ({ ...prev, categories: [cat] })); 452 if (currentRoute === ROUTES.desktop) navigateToDesktop('archive'); 453 else navigateTo(ROUTES.archive); 454 }, [currentRoute]); 455 456 const handleFilterSearch = useCallback((term) => { 457 setFilters(prev => ({ ...prev, search: term })); 458 if (currentRoute === ROUTES.desktop) navigateToDesktop('archive'); 459 else navigateTo(ROUTES.archive); 460 }, [currentRoute]); 461 462 const handleRecordEntitySelect = useCallback((entityId) => { 463 setSelectedEntityId(entityId); 464 if (currentRoute === ROUTES.desktop) navigateToDesktop('entities', entityId); 465 else navigateTo(ROUTES.entities, null, entityId); 466 }, [currentRoute]); 467 468 const handleQueryResults = useCallback((recordIds) => { 469 setFilters(prev => ({ ...prev, recordIds })); 470 navigateTo(ROUTES.archive); 471 }, []); 472 473 const handleDesktopQueryResults = useCallback((recordIds) => { 474 setFilters(prev => ({ ...prev, recordIds })); 475 navigateToDesktop('archive'); 476 }, []); 477 478 const clearQueryFilter = useCallback(() => { 479 setFilters(prev => ({ ...prev, recordIds: null })); 480 }, []); 481 482 // Broken record (#754): five quick clicks on one category filter. The list 483 // skips like a stuck record, and a note says how long that category has been 484 // repeating itself. A category with fewer than two records shows nothing. 485 const handleCategoryStreak = useCallback((category) => { 486 const summary = summarizeBrokenRecord(category, records); 487 if (!summary) return; 488 setBrokenRecord(summary); 489 setRecordsSkipping(true); 490 }, [records]); 491 492 // The hidden route links to the essay behind its one line. 493 const openNowhereEssay = useCallback(() => { 494 navigateTo(ROUTES.archive, NOWHERE_RECORD_ID); 495 selectRecord(NOWHERE_RECORD_ID); 496 }, [selectRecord]); 497 498 // Tool selection handler 499 const handleToolSelect = useCallback((action) => { 500 if (action === 'start') { 501 navigateTo(ROUTES.start); 502 } else if (action === 'mindmap') { 503 navigateTo(ROUTES.dissertation); 504 } else if (action === 'entities') { 505 navigateTo(ROUTES.entities); 506 } else if (action === 'wiki') { 507 navigateTo(ROUTES.wiki); 508 } else if (action === 'desktop') { 509 navigateToDesktop(); 510 } 511 }, []); 512 513 // Load Data 514 // Gate the ~13MB core fetch so cold deep-links into non-record routes do 515 // not fetch and parse archive data those routes never render. The ref makes 516 // this fire once; depending on currentRoute means navigating back to a 517 // record-backed route back-fills the data.
518 const coreFetchStarted = useRef(false); 519 useEffect(() => { 520 let active = true; 521 loadReleaseMetadata() 522 .then((metadata) => { 523 if (active) setReleaseMetadata(metadata); 524 }) 525 .catch(() => {}); 526 return () => { active = false; }; 527 }, []); 528 529 useEffect(() => { 530 if (coreFetchStarted.current) return; 531 if (NON_RECORD_ROUTES.has(currentRoute) && !desktopNeedsRecords) return; 532 coreFetchStarted.current = true; 533 perfMark('data:start'); 534 fetchCoreData() 535 .then((data) => { 536 perfMark('data:end'); 537 perfMeasure('data:load', 'data:start', 'data:end'); 538 setRecords(data.records); 539 setFacets(data.facets); 540 setAutocompleteIndex(data.autocompleteIndex); 541 setLoading(false); 542 }) 543 .catch(err => { 544 console.error(err); 545 setError('Failed to load archive data. Please refresh the page or try again later.'); 546 setLoading(false); 547 }); 548 }, [currentRoute, desktopAppId, desktopNeedsRecords]); 549 550 // Preserve the standard archive's background full-detail warmup, but keep 551 // the lighter guided and entity desktop surfaces on core data until a 552 // record is actually opened. Archive/Folders opt back in even when they are 553 // restored in a background window. Cancelling the timer when the route or 554 // visible window set changes avoids paying for a surface the visitor left. 555 useEffect(() => { 556 const detailsPreloadEnabled = currentRoute !== ROUTES.desktop 557 || desktopNeedsDetailsPreload; 558 if (!records.length || !detailsPreloadEnabled) return undefined; 559 560 const timer = setTimeout(() => preloadDetails(), 1000); 561 return () => clearTimeout(timer); 562 }, [records.length, currentRoute, desktopNeedsDetailsPreload]); 563 564 useEffect(() => { 565 const handleScroll = () => { 566 setIsScrolled(window.scrollY > 10); 567 setShowBackToTop(window.scrollY > 500); 568 }; 569 handleScroll(); 570 window.addEventListener('scroll', handleScroll); 571 return () => window.removeEventListener('scroll', handleScroll); 572 }, []); 573 574 // Precompute a normalized search blob per record once per data load (#456), 575 // so the per-keystroke filter normalizes only the term and runs one 576 // includes() per record instead of re-normalizing every field every keystroke. 577 const queryRecords = useMemo( 578 () => intersectByRecordIds(records, filters.recordIds), 579 [records, filters.recordIds] 580 ); 581 582 const queryFacets = useMemo( 583 () => filters.recordIds === null ? facets : deriveFacetsForRecords(facets, queryRecords, filters), 584 [facets, queryRecords, filters.recordIds, filters.categories, filters.era] 585 ); 586 587 const searchIndex = useMemo(() => queryRecords.map(buildSearchText), [queryRecords]); 588 589 // Lazy article and social full-text indexes (#276, #669), unioned with the 590 // substring blob above. Loaded on first non-empty search so browse-only visits 591 // never pay for MiniSearch or either artifact. Successful siblings remain in 592 // use while a missing artifact retries after a cooldown. miniRevision re-runs 593 // the filter whenever the available index set changes. 594 const miniRefs = useRef([]); 595 const miniLoading = useRef(false); 596 const miniMounted = useRef(true); 597 const miniRetryAfter = useRef(0); 598 const miniRetryAttempts = useRef(0); 599 const miniRetryExhausted = useRef(false); 600 const miniRetryTimer = useRef(null); 601 const [miniRevision, setMiniRevision] = useState(0); 602 const [miniComplete, setMiniComplete] = useState(false); 603 const [miniRetryTick, setMiniRetryTick] = useState(0); 604 605 useEffect(() => { 606 miniMounted.current = true; 607 return () => { 608 miniMounted.current = false; 609 if (miniRetryTimer.current !== null) clearTimeout(miniRetryTimer.current); 610 }; 611 }, []); 612 613 useEffect(() => { 614 if ( 615 !filters.search.trim() 616 || miniComplete 617 || miniLoading.current 618 || miniRetryExhausted.current 619 ) return; 620 // A persistently missing index (for example, during a staggered deploy) 621 // should not be refetched on every keystroke. The timer triggers a real 622 // retry after the cooldown even when the query itself has not changed. 623 if (performance.now() < miniRetryAfter.current) return; 624 625 const scheduleRetry = () => { 626 if (miniRetryAttempts.current >= MAX_MINI_INDEX_RETRIES) { 627 miniRetryExhausted.current = true; 628 return; 629 } 630 miniRetryAttempts.current += 1; 631 miniRetryAfter.current = performance.now() + 10000; 632 if (miniRetryTimer.current !== null) clearTimeout(miniRetryTimer.current); 633 miniRetryTimer.current = setTimeout(() => { 634 miniRetryTimer.current = null; 635 setMiniRetryTick(tick => tick + 1); 636 }, 10000); 637 }; 638 639 miniLoading.current = true; 640 loadSearchIndex() 641 .then((result) => { 642 if (!miniMounted.current) return; 643 miniRefs.current = result.indexes; 644 setMiniRevision(revision => revision + 1); 645 setMiniComplete(result.complete); 646 if (result.complete) { 647 miniRetryAfter.current = 0; 648 miniRetryAttempts.current = 0; 649 miniRetryExhausted.current = false;
650 if (miniRetryTimer.current !== null) clearTimeout(miniRetryTimer.current); 651 miniRetryTimer.current = null; 652 } else { 653 scheduleRetry(); 654 } 655 }) 656 .catch((err) => { 657 if (!miniMounted.current) return; 658 scheduleRetry(); 659 console.warn('[search] full-text index unavailable; substring search only:', err.message); 660 }) 661 .finally(() => { miniLoading.current = false; }); 662 }, [filters.search, miniComplete, miniRetryTick]); 663 664 // Turning the toggle on loads the vectors and the model before the reader 665 // types, and tells us how many articles the semantic leg covers. A failure 666 // here is not fatal: the toggle drops back to off with a "not available" 667 // line, and search stays lexical. 668 const handleSemanticToggle = useCallback((next) => { 669 // Relevance is only listed while a query is active, so the sort follows the 670 // toggle only when there is something to rank. Selecting it with an empty 671 // search box would leave the sort control blank. 672 const hasQuery = Boolean(filters.search.trim()); 673 setSemanticEnabled(next); 674 setSemanticHits(EMPTY_SEMANTIC_HITS); 675 setSortBy(prev => sortForSemanticToggle(prev, { enabled: next, hasQuery })); 676 if (!next) { 677 setSemanticStatus('idle'); 678 // Give back the model's memory. The browser keeps the download cached, so 679 // turning it on again reloads from disk rather than from the network. 680 terminateSemanticSearch(); 681 return; 682 } 683 setSemanticStatus('loading'); 684 warmupSemanticSearch() 685 .then((result) => { 686 setSemanticCoverage(result.count); 687 setSemanticStatus('ready'); 688 }) 689 .catch((err) => { 690 // Terminating the worker aborts this request. That is the reader 691 // switching the toggle off, not a failure to report. 692 if (err.name === 'AbortError') return; 693 console.warn('[search] semantic search unavailable; keyword search only:', err.message); 694 setSemanticEnabled(false); 695 setSemanticStatus('error'); 696 setSortBy(prev => sortForSemanticToggle(prev, { enabled: false, hasQuery })); 697 }); 698 }, [filters.search]); 699 700 // One encode per settled query. The request is dropped, not cancelled, when 701 // the reader types again: the worker keeps the model it already loaded. 702 useEffect(() => { 703 const query = filters.search.trim(); 704 if (!semanticEnabled || !query) { 705 setSemanticHits(EMPTY_SEMANTIC_HITS); 706 return undefined; 707 } 708 const controller = new AbortController(); 709 const timer = setTimeout(() => { 710 setSemanticStatus(prev => (prev === 'ready' ? 'searching' : prev)); 711 requestSemanticSearch(query, { signal: controller.signal }) 712 .then((result) => { 713 setSemanticHits({ query, ids: result.matches.map(match => match.id) }); 714 setSemanticCoverage(result.count); 715 setSemanticStatus('ready'); 716 }) 717 .catch((err) => { 718 if (err.name === 'AbortError') return; 719 console.warn('[search] semantic query failed; keyword results only:', err.message); 720 setSemanticHits(EMPTY_SEMANTIC_HITS); 721 setSemanticStatus('error'); 722 setSemanticEnabled(false); 723 // Same recovery as a failed warmup: the semantic leg is gone, so the 724 // sort it selected goes with it. 725 setSortBy(prev => sortForSemanticToggle(prev, { enabled: false, hasQuery: true })); 726 }); 727 }, SEMANTIC_QUERY_DEBOUNCE_MS); 728 return () => { 729 clearTimeout(timer); 730 controller.abort(); 731 }; 732 }, [semanticEnabled, filters.search]); 733 734 // Fused hybrid ranking (#279). The lexical leg is the MiniSearch hit order, 735 // the semantic leg is the cosine order, and RRF merges them on rank alone 736 // because BM25 scores and cosine similarities are not on a shared scale. 737 // `signals` carries each hit's provenance chip: kw, sem, or kw·sem. 738 const { fusedRanks, searchSignals, semanticIds, lexicalIds } = useMemo(() => { 739 const rawTerm = filters.search.trim(); 740 const lexicalOrder = rawTerm && miniRevision > 0 && miniRefs.current.length > 0 741 ? searchLoadedIndexes(miniRefs.current, rawTerm).map(hit => hit.id) 742 : []; 743 const semanticOrder = semanticEnabled && semanticHits.query === rawTerm 744 ? semanticHits.ids 745 : []; 746 return buildSearchRanking({ lexicalOrder, semanticOrder }); 747 }, [filters.search, miniRevision, semanticEnabled, semanticHits]); 748 749 const filteredRecords = useMemo(() => {
750 const term = normalizeForSearch(filters.search); 751 // Union the substring blob with both MiniSearch full-text indexes: a record 752 // matches if any path matches. Additive by design, so records absent from a 753 // prebuilt index stay covered by substring, and nothing regresses while the 754 // indexes are still loading. combineWith AND + prefix = "every word present, 755 // last word may be a prefix", how a search box is expected to narrow. 756 const rawTerm = filters.search.trim(); 757 const parsedQuery = parseSearchQuery(rawTerm); 758 // The semantic leg is a third additive path: it can surface a record whose 759 // words never match the query at all, which is the whole point of #279. 760 let res = queryRecords.filter((r, i) => { 761 const substringMatch = parsedQuery.phraseKeys.length > 0 762 ? matchesParsedSearchText(searchIndex[i], parsedQuery) 763 : searchIndex[i].includes(term); 764 if (term && !( 765 substringMatch 766 || (lexicalIds && lexicalIds.has(r.id)) 767 || semanticIds.has(r.id) 768 )) return false; 769 770 if (filters.categories.length > 0) { 771 const hasAll = filters.categories.every(cat => r.categories.includes(cat)); 772 if (!hasAll) return false; 773 } 774 775 if (filters.year) { 776 if (r.year !== filters.year) return false; 777 } else if (filters.era) { 778 if (r.era !== filters.era) return false; 779 } 780 781 if (filters.type === 'article') { 782 if (r.type === 'social') return false; 783 } else if (filters.type) { 784 if (r.type !== 'social') return false; 785 const platform = filters.type; 786 if (platform === 'twitter' && !r.pub.toLowerCase().includes('twitter') && !r.pub.toLowerCase().includes('x.com')) return false; 787 if (platform === 'bluesky' && !r.pub.toLowerCase().includes('bluesky')) return false; 788 } 789 790 // Note: Short reply posts and thread members are now filtered during data export. 791 // No need for title-based reply filtering here. 792 793 return true; 794 }); 795 796 if (sortBy === RELEVANCE_SORT) { 797 // Fused rank first, then newest-first for everything the ranked legs did 798 // not reach (a substring-only match carries no rank to fuse). 799 return orderByFusedRank(sortRecords(res, DEFAULT_SORT), fusedRanks); 800 } 801 802 res = sortRecords(res, sortBy); 803 804 return res; 805 }, [queryRecords, searchIndex, filters, sortBy, lexicalIds, semanticIds, fusedRanks]); 806 807 useEffect(() => { 808 setCurrentPage(1); 809 }, [filters, sortBy]); 810 811 // Relevance needs a query to rank against, and the sort control only offers 812 // it while one is active. Clearing the search returns to newest-first so the 813 // control never shows a value it no longer lists. Starting a fresh query with 814 // semantic search on picks relevance back up, which is what the toggle is 815 // for; editing an active query leaves the reader's chosen sort alone. 816 const hadQueryRef = useRef(false); 817 useEffect(() => { 818 const hasQuery = Boolean(filters.search.trim()); 819 setSortBy(prev => sortForQueryChange(prev, { 820 hasQuery, 821 hadQuery: hadQueryRef.current, 822 semanticEnabled, 823 })); 824 hadQueryRef.current = hasQuery; 825 }, [filters.search, semanticEnabled]); 826 827 // Derive viewMode from route for backward-compatible logic 828 const viewMode = currentRoute === ROUTES.folders 829 || (currentRoute === ROUTES.desktop && desktopAppId === 'folders') 830 ? 'folder' 831 : 'grid'; 832 833 useEffect(() => { 834 if ((filters.search || filters.year) && viewMode === 'folder') { 835 if (currentRoute === ROUTES.desktop) navigateToDesktop('archive'); 836 else navigateTo(ROUTES.archive); 837 } 838 }, [filters.search, filters.year, currentRoute, viewMode]); 839 840 const totalPages = Math.ceil(filteredRecords.length / ITEMS_PER_PAGE); 841 const paginatedRecords = filteredRecords.slice( 842 (currentPage - 1) * ITEMS_PER_PAGE, 843 currentPage * ITEMS_PER_PAGE 844 ); 845 846 useEffect(() => { 847 const timer = window.setTimeout(() => { 848 setAnnouncedResultCount(loading 849 ? 'Loading archive' 850 : `${filteredRecords.length} record${filteredRecords.length === 1 ? '' : 's'}`); 851 }, 300); 852 return () => window.clearTimeout(timer); 853 }, [loading, filteredRecords.length]); 854 855 const folderGroups = useMemo(() => { 856 const counts = {}; 857 filteredRecords.forEach(r => {
858 r.categories.forEach(c => counts[c] = (counts[c] || 0) + 1); 859 }); 860 return Object.keys(counts) 861 .filter(cat => counts[cat] >= 10) 862 .sort((a, b) => counts[b] - counts[a]) 863 .slice(0, 6) 864 .map(cat => ({ name: cat, count: counts[cat] })); 865 }, [filteredRecords]); 866 867 const handleFolderClick = (category) => { 868 setFilters(prev => ({ ...prev, categories: [category] })); 869 if (currentRoute === ROUTES.desktop) navigateToDesktop('archive'); 870 else navigateTo(ROUTES.archive); 871 }; 872 873 const handleModalNav = (direction) => { 874 if (!selectedRecordId) return; 875 const idx = filteredRecords.findIndex(r => r.id === selectedRecordId); 876 if (idx === -1) return; 877 878 if (direction === 'next' && idx < filteredRecords.length - 1) { 879 setSelectedRecordId(filteredRecords[idx + 1].id); 880 } else if (direction === 'prev' && idx > 0) { 881 setSelectedRecordId(filteredRecords[idx - 1].id); 882 } 883 }; 884 885 const handlePageChange = (newPage) => { 886 setCurrentPage(newPage); 887 if (recordsRef.current) { 888 const offset = 100; 889 const top = recordsRef.current.getBoundingClientRect().top + window.scrollY - offset; 890 window.scrollTo({ top, behavior: 'smooth' }); 891 } 892 }; 893 894 const handleYearSelect = useCallback((year) => { 895 setFilters(prev => ({ ...prev, year, era: null })); 896 }, []); 897 898 const closeSidebar = useCallback(() => { 899 setSidebarOpen(false); 900 requestAnimationFrame(() => filterTriggerRef.current?.focus({ preventScroll: true })); 901 }, []); 902 903 const years = records.map(r => parseInt(r.year)).filter(y => !isNaN(y)); 904 const minYear = years.length ? Math.min(...years) : 0; 905 const maxYear = years.length ? Math.max(...years) : 0; 906 const releaseDate = releaseMetadata ? formatReleaseDate(releaseMetadata.updated) : ''; 907 const updateNotice = updateAvailable && html` 908 <div className="archive-update-notice" role="status"> 909 <span>A new archive release is available.</span> 910 <button type="button" onClick=${() => window.location.reload()}>Reload archive</button> 911 </div> 912 `; 913 914 // One <RecordView> element for the default archive route. RecordView owns the 915 // selected-record lookup and prev/next nav math that App.js used to compute 916 // inline and pass to two identical <RecordModal> call sites. #134 Step B. 917 const recordView = html` 918 <${RecordView} 919 records=${records} 920 filteredRecords=${filteredRecords} 921 selectedRecordId=${selectedRecordId} 922 onClose=${() => selectRecord(null)} 923 onNext=${() => handleModalNav('next')} 924 onPrev=${() => handleModalNav('prev')} 925 onSelectRecord=${selectRecord} 926 onFilterCategory=${handleFilterCategory} 927 onFilterSearch=${handleFilterSearch} 928 onSelectEntity=${handleRecordEntitySelect} 929 onReportProblem=${handleRecordProblemReport} 930 nestedDialogOpen=${bugReportOpen} 931 /> 932 `; 933 934 // One toggle state, two places to reach it: the filter sidebar search group 935 // and the compact search box above the results. Both copies can sit in the 936 // accessibility tree at once (the filter drawer over the mobile search box), 937 // so neither carries a live region: the results toolbar announces the state 938 // once for both. 939 const semanticSearchProps = { 940 enabled: semanticEnabled, 941 status: semanticStatus, 942 coverage: semanticCoverage, 943 onToggle: handleSemanticToggle, 944 }; 945 946 const isEntityBrowser = currentRoute === ROUTES.entities; 947 const isWiki = currentRoute === ROUTES.wiki; 948 const isAnalytics = currentRoute === ROUTES.analytics; 949 const isArchiveGrid = currentRoute === ROUTES.archive || currentRoute === ROUTES.folders; 950 951 const activeFilterCount = (filters.search ? 1 : 0) + 952 filters.categories.length + 953 (filters.era ? 1 : 0) + 954 (filters.year ? 1 : 0) + 955 (filters.type ? 1 : 0) + 956 (filters.recordIds !== null ? 1 : 0); 957 958 const contentTypeLabel = CONTENT_TYPE_OPTIONS.find(option => option.value === filters.type)?.label; 959 960 // Class of 1986 (#754): searching the dissertation year, and nothing else, 961 // surfaces one note. Changing the search brings the note back for the next 962 // person who lands on that exact query. 963 const dissertationYearSearch = isDissertationYearQuery(filters.search); 964 useEffect(() => { 965 if (!dissertationYearSearch) setYearNoteDismissed(false); 966 }, [dissertationYearSearch]); 967 968 // The skip is short and ends itself, so nothing keeps moving on the page. 969 useEffect(() => { 970 if (!recordsSkipping) return undefined; 971 const timer = setTimeout(() => setRecordsSkipping(false), BROKEN_RECORD_SKIP_MS); 972 return () => clearTimeout(timer); 973 }, [recordsSkipping]); 974 975 const showYearNote = isArchiveGrid && dissertationYearSearch && !yearNoteDismissed; 976 // The broken-record note stands on its own until the visitor closes it. It 977 // used to go away as soon as the category left the filter, but five clicks on 978 // one chip leave that filter off again, so the note was cleared in the same 979 // commit that set it and the line was gone before anyone could read it. The 980 // line is a remark about the category, not a description of the current view, 981 // so it reads either way. 982 const showBrokenRecordNote = isArchiveGrid && Boolean(brokenRecord); 983 984 const activeScopeTokens = [ 985 filters.search ? { key: 'search', kind: 'search', label: `Search: â${filters.search}â` } : null, 986 ...filters.categories.map(category => ({ 987 key: `category-${category}`, 988 kind: 'category', 989 value: category, 990 label: category, 991 })), 992 filters.era ? { key: 'era', kind: 'era', label: filters.era } : null, 993 filters.year ? { key: 'year', kind: 'year', label: filters.year } : null, 994 filters.type ? { key: 'type', kind: 'type', label: contentTypeLabel || filters.type } : null, 995 filters.recordIds !== null ? { key: 'query', kind: 'recordIds', label: 'Query results' } : null,
996 ].filter(Boolean); 997 const activeScopeKey = activeScopeTokens.map(token => token.key).join('|'); 998 999 const clearScopeToken = (token) => { 1000 const tokenIndex = activeScopeTokens.findIndex(candidate => candidate.key === token.key); 1001 pendingScopeFocusKey.current = activeScopeTokens[tokenIndex + 1]?.key 1002 || activeScopeTokens[tokenIndex - 1]?.key 1003 || 'status'; 1004 setFilters(prev => { 1005 if (token.kind === 'category') { 1006 return { ...prev, categories: prev.categories.filter(category => category !== token.value) }; 1007 } 1008 if (token.kind === 'search') return { ...prev, search: '' }; 1009 if (token.kind === 'era') return { ...prev, era: null }; 1010 if (token.kind === 'year') return { ...prev, year: null }; 1011 if (token.kind === 'type') return { ...prev, type: null }; 1012 if (token.kind === 'recordIds') return { ...prev, recordIds: null }; 1013 return prev; 1014 }); 1015 }; 1016 1017 const clearAllScope = () => { 1018 pendingScopeFocusKey.current = 'status'; 1019 setFilters({ ...DEFAULT_FILTERS }); 1020 }; 1021 1022 useEffect(() => { 1023 if (!pendingScopeFocusKey.current) return undefined; 1024 const frame = requestAnimationFrame(() => { 1025 const focusKey = pendingScopeFocusKey.current; 1026 pendingScopeFocusKey.current = null; 1027 const focusTarget = focusKey === 'status' 1028 ? scopeStatusRef.current 1029 : scopeTokenRefs.current.get(focusKey); 1030 focusTarget?.focus({ preventScroll: true }); 1031 }); 1032 return () => cancelAnimationFrame(frame); 1033 }, [activeScopeKey]); 1034 1035 const desktopArchiveView = { 1036 viewMode, 1037 loading, 1038 error, 1039 filters, 1040 setFilters, 1041 facets: queryFacets, 1042 autocompleteIndex, 1043 sortBy, 1044 setSortBy, 1045 filteredRecords, 1046 paginatedRecords, 1047 folderGroups, 1048 currentPage, 1049 totalPages, 1050 activeFilterCount, 1051 onSetViewMode: (mode) => navigateToDesktop(mode === 'folder' ? 'folders' : 'archive'), 1052 onSelectRecord: selectRecord, 1053 onOpenFolder: handleFolderClick, 1054 onPageChange: handlePageChange, 1055 onClearFilters: () => setFilters({ ...DEFAULT_FILTERS }), 1056 onOpenStandard: (mode = viewMode) => goTo(mode === 'folder' ? ROUTES.folders : ROUTES.archive), 1057 }; 1058 1059 const desktopEntityView = { 1060 records: queryRecords, 1061 queryActive: filters.recordIds !== null, 1062 onClearQuery: clearQueryFilter, 1063 loading, 1064 error, 1065 onSelectRecord: selectRecord, 1066 selectedEntityId, 1067 onSelectEntity: setSelectedEntityId, 1068 autoFocusSelection: !desktopRecordOverlayOpen, 1069 onOpenStandard: () => goTo(ROUTES.entities), 1070 }; 1071 1072 const desktopDissertationView = { 1073 onOpenStandard: () => goTo(ROUTES.dissertation), 1074 }; 1075 1076 const desktopAnalyticsView = { 1077 onRecordResults: handleDesktopQueryResults, 1078 onOpenStandard: () => goTo(ROUTES.analytics), 1079 }; 1080 1081 const desktopStartView = { 1082 records, 1083 loading, 1084 error, 1085 onNavigate: handleDesktopGuideNavigate, 1086 onSelectRecord: selectRecord, 1087 onOpenBugReport: () => openBugReport('problem'), 1088 onOpenStandard: () => goTo(ROUTES.start), 1089 }; 1090 1091 // Keep global overlays mounted on full-page routes. Returning a page directly 1092 // here used to strand any full-page action that opened the bug report modal, 1093 // because the modal only existed in the archive shell below. 1094 const renderFullPage = (page, routeOverlay = null) => html` 1095 <div className="min-h-screen flex flex-col archive-canvas"> 1096 ${updateNotice} 1097 ${page} 1098 ${routeOverlay} 1099 <${BugReportModal} 1100 isOpen=${bugReportOpen} 1101 onClose=${() => setBugReportOpen(false)} 1102 endpoint=${REPORT_CONFIG.endpoint} 1103 initialIntent=${bugReportIntent} 1104 initialFields=${bugReportInitialFields} 1105 /> 1106 </div> 1107 `; 1108 1109 // Full-page routes: desktop, Start here, dissertation, about, analytics 1110 if (currentRoute === ROUTES.desktop) { 1111 const desktopFallback = html` 1112 <main id="main-content" className="min-h-screen px-4 py-16 text-white" style=${{ backgroundColor: '#0b6867' }}> 1113 <div className="mx-auto max-w-lg border-2 border-white/70 bg-black/20 p-6"> 1114 <p className="font-display text-xl" role="status">Opening the archive desktop...</p> 1115 <button 1116 type="button" 1117 onClick=${() => goTo(ROUTES.archive)} 1118 className="mt-5 border-2 border-white px-4 py-3 font-body text-sm font-bold focus:outline-none focus:ring-2 focus:ring-amber-300" 1119 > 1120 Return to the standard archive 1121 </button> 1122 </div> 1123 </main> 1124 `; 1125 1126 return renderFullPage(html` 1127 <${DesktopRouteErrorBoundary} onExit=${() => goTo(ROUTES.archive)}> 1128 <${Suspense} fallback=${desktopFallback}> 1129 <${DesktopShell} 1130 activeAppId=${desktopAppId} 1131 autoFocusWindow=${!desktopRecordOverlayOpen} 1132 onSelectApp=${goToDesktop} 1133 onNavigate=${goTo} 1134 onOpenBugReport=${() => openBugReport('problem')} 1135 onExit=${() => goTo(ROUTES.archive)} 1136 onOpenAppsChange=${setDesktopOpenAppIds} 1137 archiveView=${desktopArchiveView} 1138 analyticsView=${desktopAnalyticsView} 1139 dissertationView=${desktopDissertationView} 1140 entityView=${desktopEntityView} 1141 startView=${desktopStartView} 1142 /> 1143 <//> 1144 <//> 1145 `, desktopActiveNeedsRecords ? recordView : null); 1146 } 1147 1148 if (currentRoute === ROUTES.start) { 1149 const handleStartRecordSelect = (recordOrId) => { 1150 const id = typeof recordOrId === 'string' ? recordOrId : recordOrId?.id; 1151 if (!id || !records.some(record => record.id === id)) return; 1152 navigateTo(ROUTES.archive, id); 1153 selectRecord(id); 1154 }; 1155 1156 return renderFullPage(html` 1157 <${StartHerePage} 1158 onBack=${() => goTo(ROUTES.archive)} 1159 records=${records} 1160 onNavigate=${goTo} 1161 onOpenBugReport=${() => openBugReport('problem')} 1162 onSelectRecord=${handleStartRecordSelect} 1163 /> 1164 `); 1165 } 1166 1167 if (currentRoute === ROUTES.dissertation) { 1168 return renderFullPage(html`<${DissertationPage} onBack=${() => goTo(ROUTES.archive)} />`); 1169 } 1170 1171 if (currentRoute === ROUTES.about) { 1172 return renderFullPage(html` 1173 <${AboutPage} 1174 onBack=${() => goTo(ROUTES.archive)} 1175 onStart=${() => goTo(ROUTES.start)} 1176 onParticipate=${() => { window.location.href = resolveSitePath('features/participate/'); }} 1177 records=${records} 1178 /> 1179 `); 1180 } 1181 1182 // Hidden route (#754). Nothing links here; it is reached only by typing the 1183 // hash. It renders a normal full page so anyone who finds it can read it, 1184 // focus it, and leave it. 1185 if (currentRoute === ROUTES.nowhere) { 1186 return renderFullPage(html` 1187 <${NowherePage} 1188 onBack=${() => goTo(ROUTES.archive)} 1189 onOpenEssay=${openNowhereEssay} 1190 essayHref=${canonicalRecordUrl(window.location.href, NOWHERE_RECORD_ID)} 1191 /> 1192 `); 1193 } 1194 1195 if (isAnalytics) { 1196 return renderFullPage(html` 1197 <${AnalyticsDashboard} 1198 onBack=${() => goTo(ROUTES.archive)} 1199 onRecordResults=${handleQueryResults} 1200 /> 1201 `); 1202 } 1203 1204 // Shared fail-loud panel for the record-backed routes. fetchCoreData throws 1205 // on a core-data outage (#290), so both the archive grid and the entity 1206 // browser depend on the same load; render the same error on each instead of 1207 // letting the entity route show an empty browser with no explanation (#369). 1208 const errorPanel = error && html` 1209 <div className="archive-error-state" role="alert"> 1210 <${AlertCircle} className="archive-error-state__icon" aria-hidden="true" /> 1211 <p className="archive-section-label">Archive status</p> 1212 <h3>Error loading archive</h3> 1213 <p>${error}</p> 1214 <button 1215 type="button" 1216 onClick=${() => window.location.reload()} 1217 className="archive-action archive-action--danger" 1218 > 1219 Reload page 1220 </button> 1221 </div> 1222 `; 1223 1224 return html` 1225 <div className="min-h-screen flex flex-col"> 1226 <${WelcomeModal} 1227 onStart=${() => goTo(ROUTES.start)} 1228 /> 1229 1230 <${ToolsModal} 1231 isOpen=${toolsModalOpen} 1232 onClose=${() => setToolsModalOpen(false)} 1233 onSelectTool=${handleToolSelect} 1234 /> 1235 1236 <${BugReportModal} 1237 isOpen=${bugReportOpen} 1238 onClose=${() => setBugReportOpen(false)} 1239 endpoint=${REPORT_CONFIG.endpoint} 1240 initialIntent=${bugReportIntent} 1241 initialFields=${bugReportInitialFields} 1242 /> 1243 1244 ${recordView} 1245 1246 <${WorkInProgressBanner} /> 1247 ${updateNotice} 1248 1249 <header className=${`archive-site-header sticky top-0 z-50 w-full transition-all duration-300 ${ 1250 isScrolled 1251 ? 'archive-site-header--scrolled' 1252 : '' 1253 }`}>
1254 <div className="container mx-auto px-4 h-16 flex items-center justify-between"> 1255 <button 1256 onClick=${() => goTo(ROUTES.archive)} 1257 className="archive-site-header__brand" 1258 aria-label="Return to archive home" 1259 > 1260 <div className="archive-site-header__mark"> 1261 <${Newspaper} className="w-5 h-5" /> 1262 </div> 1263 <h1 className="text-lg md:text-xl font-display font-bold text-stone-900 tracking-tight hidden sm:block"> 1264 Jay Rosen's Internet Archive 1265 </h1> 1266 <h1 className="text-lg font-display font-bold text-stone-900 sm:hidden">JRIA</h1> 1267 </button> 1268 1269 <div className="archive-site-header__stats hidden md:flex items-center gap-6 px-6 h-full"> 1270 <div className="flex flex-col leading-tight"> 1271 <span className="font-bold text-stone-900">${records.length}</span> 1272 <span>records</span> 1273 </div> 1274 <div className="h-6 w-px bg-stone-200"></div> 1275 <div className="flex flex-col leading-tight"> 1276 <span className="font-bold text-stone-900">${minYear}â${maxYear}</span> 1277 <span>timeline</span> 1278 </div> 1279 </div> 1280 1281 <div className="flex items-center gap-4"> 1282 <button 1283 onClick=${() => setToolsModalOpen(true)} 1284 className="archive-action archive-action--quiet archive-site-header__action archive-site-header__tools sm:hidden" 1285 aria-label="Tools" 1286 > 1287 <${Compass} className="w-5 h-5" /> 1288 </button> 1289 <button 1290 onClick=${() => goTo(ROUTES.about)} 1291 className="archive-action archive-action--quiet archive-site-header__action archive-site-header__about hidden md:flex" 1292 aria-label="About" 1293 > 1294 <${Info} className="w-4 h-4" /> 1295 <span>About</span> 1296 </button> 1297 <button 1298 onClick=${() => openBugReport('problem')} 1299 className="archive-action archive-action--quiet archive-site-header__action" 1300 aria-label="Report a bug" 1301 title="Report a bug" 1302 > 1303 <${Bug} className="w-4 h-4" /> 1304 <span className="hidden md:inline">Report a bug</span> 1305 </button> 1306 <a href="https://github.com/jamditis" target="_blank" rel="noreferrer" className="archive-site-header__credit"> 1307 Curated by Joe Amditis 1308 </a> 1309 ${isArchiveGrid && html` 1310 <button 1311 type="button" 1312 ref=${filterTriggerRef} 1313 onClick=${() => setSidebarOpen(true)} 1314 className="archive-site-header__filter lg:hidden p-2 text-stone-800 hover:bg-stone-100 rounded-md border border-stone-300 relative" 1315 aria-label="Open archive filters" 1316 aria-expanded=${sidebarOpen} 1317 aria-controls="archive-filters" 1318 > 1319 <${SlidersHorizontal} className="w-5 h-5" /> 1320 ${activeFilterCount > 0 && html` 1321 <span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center font-bold"> 1322 ${activeFilterCount} 1323 </span> 1324 `} 1325 </button> 1326 `} 1327 </div> 1328 </div> 1329 </header> 1330 1331 <div className=${`archive-discovery-layout flex-grow container mx-auto px-4 py-6 flex gap-8 ${isEntityBrowser || isWiki ? 'justify-center' : ''}`}> 1332 1333 ${isArchiveGrid && html` 1334 <${Sidebar} 1335 facets=${queryFacets} 1336 filters=${filters} 1337 setFilters=${setFilters} 1338 isOpen=${sidebarOpen} 1339 onClose=${closeSidebar} 1340 resetFilters=${() => setFilters({ ...DEFAULT_FILTERS })} 1341 autocompleteIndex=${autocompleteIndex} 1342 semanticSearch=${semanticSearchProps} 1343 onCategoryStreak=${handleCategoryStreak} 1344 /> 1345 `} 1346 1347 <main 1348 id="main-content" 1349 data-route-entry-focus 1350 tabIndex="-1" 1351 className="flex-grow min-w-0 flex flex-col outline-none" 1352 > 1353 1354 ${isArchiveGrid && html` 1355 <div className="archive-discovery-intro"> 1356 ${activeFilterCount === 0 && html` 1357 <section className="archive-tools-strip archive-density--compact mb-6" aria-labelledby="homepage-tools-title"> 1358 <h2 id="homepage-tools-title" className="archive-folder-tab archive-tools-strip__tab"><span>Tools</span></h2> 1359 <div className="archive-tools-strip__items"> 1360 <button 1361 onClick=${() => goTo(ROUTES.start)} 1362 className="archive-action archive-action--quiet archive-tools-strip__wide" 1363 > 1364 <${Compass} className="w-3.5 h-3.5" aria-hidden="true" /> 1365 Start here
1366 </button> 1367 <button 1368 onClick=${() => goTo(ROUTES.dissertation)} 1369 className="archive-action archive-action--quiet" 1370 > 1371 <${BookOpen} className="w-3.5 h-3.5" aria-hidden="true" /> 1372 Mind map 1373 </button> 1374 <a 1375 href=${resolveSitePath('dissertation/reader/')} 1376 className="archive-action archive-action--quiet archive-tools-strip__wide" 1377 > 1378 <${BookMarked} className="w-3.5 h-3.5" aria-hidden="true" /> 1379 Dissertation reader 1380 </a> 1381 <button 1382 onClick=${() => goTo(ROUTES.entities)} 1383 className="archive-action archive-action--quiet" 1384 > 1385 <${Users} className="w-3.5 h-3.5" aria-hidden="true" /> 1386 Entities 1387 </button> 1388 <button 1389 onClick=${() => goTo(ROUTES.analytics)} 1390 className="archive-action archive-action--quiet" 1391 > 1392 <${BarChart3} className="w-3.5 h-3.5" aria-hidden="true" /> 1393 Analytics 1394 </button> 1395 <a 1396 href=${resolveSitePath('faq/')} 1397 className="archive-action archive-action--quiet archive-tools-strip__wide" 1398 > 1399 <${HelpCircle} className="w-3.5 h-3.5" aria-hidden="true" /> 1400 FAQ 1401 </a> 1402 <button 1403 onClick=${() => setToolsModalOpen(true)} 1404 className="archive-action archive-action--quiet" 1405 aria-label="More tools" 1406 aria-haspopup="dialog" 1407 > 1408 <${MoreHorizontal} className="w-3.5 h-3.5" aria-hidden="true" /> 1409 More 1410 </button> 1411 </div> 1412 </section> 1413 `} 1414 </div> 1415 `} 1416 1417 ${isWiki && html` 1418 <${WikiPage} /> 1419 `} 1420 1421 ${isArchiveGrid && html` 1422 <div className="archive-mobile-search"> 1423 <label htmlFor="archive-mobile-search">Search archive</label> 1424 <div className="archive-mobile-search__control"> 1425 <${Search} className="archive-mobile-search__icon" aria-hidden="true" /> 1426 <input 1427 id="archive-mobile-search" 1428 type="search" 1429 value=${filters.search} 1430 onChange=${event => setFilters(prev => ({ ...prev, search: event.target.value }))} 1431 placeholder="Keywords or title" 1432 className="archive-control" 1433 /> 1434 ${filters.search && html` 1435 <button 1436 type="button" 1437 onClick=${() => setFilters(prev => ({ ...prev, search: '' }))} 1438 aria-label="Clear archive search" 1439 > 1440 <${XCircle} aria-hidden="true" /> 1441 </button> 1442 `} 1443 </div> 1444 <${SemanticSearchToggle} 1445 ...${semanticSearchProps} 1446 inputId="archive-mobile-semantic-search" 1447 /> 1448 </div> 1449 1450 ${showYearNote && html` 1451 <${EasterEggNote} 1452 text=${DISSERTATION_YEAR_NOTE} 1453 linkHref=${resolveSitePath('dissertation/reader/')} 1454 linkLabel="Read the dissertation" 1455 onDismiss=${() => setYearNoteDismissed(true)} 1456 /> 1457 `} 1458 1459 ${showBrokenRecordNote && html` 1460 <${EasterEggNote} 1461 text=${brokenRecordLine(brokenRecord)} 1462 onDismiss=${() => setBrokenRecord(null)} 1463 /> 1464 `} 1465 1466 <div className="archive-results-toolbar scroll-mt-24" ref=${recordsRef}> 1467 <div className="archive-results-summary">
1468 <span 1469 ref=${scopeStatusRef} 1470 tabIndex="-1" 1471 className="archive-results-count" 1472 > 1473 ${loading 1474 ? 'Loading archive...' 1475 : `${filteredRecords.length} record${filteredRecords.length === 1 ? '' : 's'}${filters.recordIds !== null ? ' in query results' : ''}`} 1476 </span> 1477 ${releaseMetadata && html` 1478 <span className="archive-results__updated"> 1479 Site updated <time dateTime=${releaseMetadata.updated}>${releaseDate}</time> 1480 </span> 1481 `} 1482 <span className="sr-only" role="status" aria-live="polite" aria-atomic="true"> 1483 ${announcedResultCount} 1484 </span> 1485 <span 1486 className="sr-only" 1487 data-semantic-status 1488 role="status" 1489 aria-live="polite" 1490 aria-atomic="true" 1491 > 1492 ${semanticStatusMessage(semanticSearchProps)} 1493 </span> 1494 ${activeScopeTokens.length > 0 && html` 1495 <div className="archive-results-scope" aria-label="Active archive scope"> 1496 ${activeScopeTokens.map(token => html` 1497 <button 1498 type="button" 1499 key=${token.key} 1500 ref=${element => { 1501 if (element) scopeTokenRefs.current.set(token.key, element); 1502 else scopeTokenRefs.current.delete(token.key); 1503 }} 1504 data-scope-key=${token.key} 1505 className="archive-scope-token" 1506 onClick=${() => clearScopeToken(token)} 1507 aria-label=${`Remove ${token.label} filter`} 1508 > 1509 <span>${token.label}</span> 1510 <${XCircle} aria-hidden="true" /> 1511 </button> 1512 `)} 1513 ${activeScopeTokens.length > 1 && html` 1514 <button 1515 type="button" 1516 className="archive-scope-clear" 1517 onClick=${clearAllScope} 1518 > 1519 Clear all 1520 </button> 1521 `} 1522 </div> 1523 `} 1524 ${viewMode === 'folder' && html` 1525 <p className="archive-folder-note">Records may appear in more than one folder.</p> 1526 `} 1527 </div> 1528 <div className="archive-results-controls"> 1529 <div className="archive-view-switch" role="group" aria-label="Archive view"> 1530 <button 1531 type="button" 1532 onClick=${() => goTo(ROUTES.archive)} 1533 className=${`archive-view-switch__button ${currentRoute === ROUTES.archive ? 'is-active' : ''}`} 1534 aria-pressed=${currentRoute === ROUTES.archive} 1535 aria-label="Cards view" 1536 title="Cards view" 1537 > 1538 <${LayoutGrid} className="w-3 h-3" /> <span className="hidden sm:inline">Cards</span> 1539 </button> 1540 <button 1541 type="button" 1542 onClick=${() => goTo(ROUTES.folders)} 1543 className=${`archive-view-switch__button ${currentRoute === ROUTES.folders ? 'is-active' : ''}`} 1544 aria-pressed=${currentRoute === ROUTES.folders} 1545 aria-label="Folders view" 1546 title="Folders view" 1547 > 1548 <${Folder} className="w-3 h-3" /> <span className="hidden sm:inline">Folders</span> 1549 </button> 1550 </div> 1551 1552 ${viewMode === 'grid' && html`<label className="archive-sort-control" htmlFor="sort-select">
1553 <span>Sort</span> 1554 <select 1555 id="sort-select" 1556 aria-label="Sort archive records" 1557 value=${sortBy} 1558 onChange=${(e) => setSortBy(e.target.value)} 1559 className="archive-control archive-sort-control__select" 1560 > 1561 ${Boolean(filters.search.trim()) && html` 1562 <option value=${RELEVANCE_SORT}>Relevance</option> 1563 `} 1564 <option value="date-desc">Newest first</option> 1565 <option value="date-asc">Oldest first</option> 1566 <option value="title-asc">Title (A-Z)</option> 1567 </select> 1568 </label>`} 1569 </div> 1570 </div> 1571 `} 1572 1573 ${currentRoute === ROUTES.archive && !loading && !filters.search && !filters.era && filters.categories.length === 0 && html` 1574 <${Timeline} 1575 records=${queryRecords} 1576 selectedYear=${filters.year} 1577 onSelectYear=${handleYearSelect} 1578 /> 1579 `} 1580 1581 ${isEntityBrowser && html` 1582 <div className="flex flex-col sm:flex-row sm:items-center justify-between mb-6 gap-4 border-b border-stone-200 pb-4 scroll-mt-24"> 1583 <div className="font-display text-stone-500 text-sm"> 1584 Browse entities and their connections 1585 </div> 1586 </div> 1587 `} 1588 1589 ${isEntityBrowser && errorPanel} 1590 1591 ${isEntityBrowser && !loading && !error && html` 1592 <${EntityBrowser} 1593 records=${queryRecords} 1594 queryActive=${filters.recordIds !== null} 1595 onClearQuery=${clearQueryFilter} 1596 onSelectRecord=${selectRecord} 1597 selectedEntityId=${selectedEntityId} 1598 onSelectEntity=${setSelectedEntityId} 1599 autoFocusSelection=${!selectedRecordId} 1600 /> 1601 `} 1602 1603 ${isArchiveGrid && html` 1604 <${ArchiveResults} 1605 errorPanel=${errorPanel} 1606 loading=${loading} 1607 filteredRecords=${filteredRecords} 1608 paginatedRecords=${paginatedRecords} 1609 folderGroups=${folderGroups} 1610 viewMode=${viewMode} 1611 searchTerm=${filters.search} 1612 searchSignals=${searchSignals} 1613 currentPage=${currentPage} 1614 totalPages=${totalPages} 1615 onSelectRecord=${selectRecord} 1616 onOpenFolder=${handleFolderClick} 1617 onPageChange=${handlePageChange} 1618 onClearFilters=${() => setFilters({ ...DEFAULT_FILTERS })} 1619 skipping=${recordsSkipping} 1620 /> 1621 `} 1622 1623 ${currentRoute === ROUTES.archive && !loading && activeFilterCount === 0 && html` 1624 <${FeaturedSection} /> 1625 `} 1626 </main> 1627 1628 ${showBackToTop && html` 1629 <button 1630 onClick=${() => window.scrollTo({ top: 0, behavior: 'smooth' })} 1631 className="archive-back-to-top fixed bottom-6 right-6 z-40 p-3 bg-stone-800 text-white rounded-full shadow-lg hover:bg-stone-700 transition-all hover:scale-110" 1632 aria-label="Back to top" 1633 > 1634 <${ChevronUp} className="w-5 h-5" /> 1635 </button> 1636 `} 1637 </div> 1638 1639 <footer className="archive-site-footer mt-auto"> 1640 <div className="container mx-auto px-4 py-6"> 1641 <div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8 text-sm text-stone-600"> 1642 <div> 1643 <h4 className="archive-site-footer__heading">Jay Rosen's Internet Archive</h4> 1644 <p className="text-xs leading-relaxed"> 1645 A curated public collection of the works, critiques, and teachings of Jay Rosen, professor of journalism at New York University. 1646 </p> 1647 </div> 1648 <div> 1649 <h4 className="archive-site-footer__heading">Sections</h4> 1650 <div className="archive-site-footer__links text-xs"> 1651 <button onClick=${() => goTo(ROUTES.start)} className="archive-site-footer__link">Start here</button> 1652 <a href=${resolveSitePath('features/participate/')} className="archive-site-footer__link">Ways to participate</a> 1653 <button onClick=${() => goTo(ROUTES.archive)} className="archive-site-footer__link">Browse archive</button> 1654 <button onClick=${() => goTo(ROUTES.dissertation)} className="archive-site-footer__link">Dissertation mind map</button> 1655 <button onClick=${() => goTo(ROUTES.entities)} className="archive-site-footer__link">Entity browser</button> 1656 <button onClick=${() => goTo(ROUTES.analytics)} className="archive-site-footer__link">Analytics dashboard</button> 1657 <button onClick=${() => goTo(ROUTES.about)} className="archive-site-footer__link">About this archive</button> 1658 <a href=${getPrivacyDetailsHref()} className="archive-site-footer__link">Privacy and browser storage</a> 1659 </div> 1660 </div> 1661 <div> 1662 <h4 className="archive-site-footer__heading">Credits</h4> 1663 <p className="text-xs leading-relaxed mb-2"> 1664 Curated by <a href="https://github.com/jamditis" target="_blank" rel="noreferrer" className="text-stone-900 font-bold hover:underline">Joe Amditis</a> 1665 </p> 1666 <p className="text-xs text-stone-600"> 1667 ${records.length} records | ${minYear}â${maxYear} 1668 </p> 1669 ${releaseMetadata && html` 1670 <p className="archive-site-footer__release"> 1671 Last updated <time dateTime=${releaseMetadata.updated}>${releaseDate}</time>
1672 <span aria-hidden="true"> · </span> 1673 Version ${releaseMetadata.version} 1674 </p> 1675 `} 1676 </div> 1677 </div> 1678 </div> 1679 </footer> 1680 </div> 1681 `; 1682} 1683 1684export default App;
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.