Tools for the Atmosphere tools.slices.network
quickslice atproto html
at main 1146 lines 34 kB view raw
1<!doctype html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <title>{ Lexicon Explorer }</title> 7 <style> 8 /* CSS Reset */ 9 *, 10 *::before, 11 *::after { 12 box-sizing: border-box; 13 } 14 * { 15 margin: 0; 16 } 17 html { 18 height: 100%; 19 overflow: hidden; 20 } 21 body { 22 line-height: 1.5; 23 -webkit-font-smoothing: antialiased; 24 } 25 input, 26 button { 27 font: inherit; 28 } 29 30 /* Theme Variables */ 31 :root { 32 --bg-primary: #f5f5f5; 33 --bg-card: #ffffff; 34 --bg-input: #fafafa; 35 --text-primary: #1a1a1a; 36 --text-secondary: #666666; 37 --text-muted: #999999; 38 --accent: #0066cc; 39 --accent-hover: #0052a3; 40 --border: #e0e0e0; 41 --border-focus: #0066cc; 42 --error-text: #dc2626; 43 --json-key: #666666; 44 --json-string: #0066cc; 45 --json-number: #16a34a; 46 --json-boolean: #9333ea; 47 --json-null: #9333ea; 48 --json-bracket: #999999; 49 } 50 51 body { 52 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 53 background: var(--bg-primary); 54 color: var(--text-primary); 55 height: 100%; 56 padding: 2rem 1rem; 57 display: flex; 58 flex-direction: column; 59 overflow: hidden; 60 } 61 62 #app { 63 max-width: 700px; 64 margin: 0 auto; 65 width: 100%; 66 flex: 1; 67 display: flex; 68 flex-direction: column; 69 min-height: 0; 70 } 71 72 #main { 73 flex: 1; 74 display: flex; 75 flex-direction: column; 76 min-height: 0; 77 } 78 79 header { 80 text-align: center; 81 margin-bottom: 2rem; 82 flex-shrink: 0; 83 } 84 85 header h1 { 86 font-size: 2rem; 87 color: var(--text-primary); 88 margin-bottom: 0.25rem; 89 } 90 91 .tagline { 92 color: var(--text-secondary); 93 font-size: 0.875rem; 94 } 95 96 .tagline a { 97 color: var(--accent); 98 text-decoration: none; 99 } 100 101 .tagline a:hover { 102 text-decoration: underline; 103 } 104 105 /* Search */ 106 .search-container { 107 position: relative; 108 margin-bottom: 1.5rem; 109 flex-shrink: 0; 110 } 111 112 .search-input { 113 width: 100%; 114 padding: 0.75rem 1rem; 115 padding-left: 2.5rem; 116 background: var(--bg-card); 117 border: 1px solid var(--border); 118 border-radius: 0.5rem; 119 color: var(--text-primary); 120 font-size: 1rem; 121 } 122 123 .search-input:focus { 124 outline: none; 125 border-color: var(--border-focus); 126 box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); 127 } 128 129 .search-input::placeholder { 130 color: var(--text-muted); 131 } 132 133 .search-icon { 134 position: absolute; 135 left: 0.875rem; 136 top: 50%; 137 transform: translateY(-50%); 138 color: var(--text-muted); 139 pointer-events: none; 140 } 141 142 .search-dropdown { 143 position: absolute; 144 top: 100%; 145 left: 0; 146 right: 0; 147 margin-top: 0.25rem; 148 background: var(--bg-card); 149 border: 1px solid var(--border); 150 border-radius: 0.5rem; 151 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 152 z-index: 100; 153 max-height: 320px; 154 overflow-y: auto; 155 } 156 157 .search-dropdown:empty { 158 display: none; 159 } 160 161 .search-result { 162 padding: 0.625rem 1rem; 163 cursor: pointer; 164 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 165 font-size: 0.8125rem; 166 } 167 168 .search-result:hover, 169 .search-result.selected { 170 background: var(--bg-primary); 171 } 172 173 .search-result:first-child { 174 border-radius: 0.5rem 0.5rem 0 0; 175 } 176 177 .search-result:last-child { 178 border-radius: 0 0 0.5rem 0.5rem; 179 } 180 181 /* Lexicon Card */ 182 .lexicon-card { 183 background: var(--bg-card); 184 border: 1px solid var(--border); 185 border-radius: 0.5rem; 186 overflow: hidden; 187 view-transition-name: lexicon-card; 188 flex: 1; 189 display: flex; 190 flex-direction: column; 191 min-height: 0; 192 } 193 194 .lexicon-card.loading { 195 opacity: 0.7; 196 } 197 198 .card-header { 199 padding: 1rem; 200 border-bottom: 1px solid var(--border); 201 flex-shrink: 0; 202 display: flex; 203 justify-content: space-between; 204 align-items: center; 205 gap: 1rem; 206 } 207 208 .card-header-content { 209 flex: 1; 210 min-width: 0; 211 } 212 213 .card-nsid { 214 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 215 font-size: 1rem; 216 font-weight: 600; 217 color: var(--text-primary); 218 margin-bottom: 0.5rem; 219 word-break: break-word; 220 } 221 222 .nsid-domain { 223 color: var(--accent); 224 text-decoration: none; 225 } 226 227 .nsid-domain:hover { 228 text-decoration: underline; 229 } 230 231 .action-btn { 232 background: none; 233 border: none; 234 padding: 0.25rem; 235 cursor: pointer; 236 font-size: 0.875rem; 237 opacity: 0.5; 238 transition: opacity 0.15s; 239 } 240 241 .action-btn:hover { 242 opacity: 1; 243 } 244 245 .action-btn.copied { 246 opacity: 1; 247 } 248 249 .action-buttons { 250 display: flex; 251 gap: 0.25rem; 252 } 253 254 .card-meta { 255 display: flex; 256 flex-wrap: wrap; 257 gap: 0.5rem 1rem; 258 font-size: 0.8125rem; 259 color: var(--text-secondary); 260 } 261 262 .card-meta-item { 263 display: flex; 264 align-items: center; 265 gap: 0.25rem; 266 } 267 268 .card-meta-label { 269 color: var(--text-muted); 270 } 271 272 .author-link { 273 color: var(--accent); 274 text-decoration: none; 275 } 276 277 .author-link:hover { 278 text-decoration: underline; 279 } 280 281 .card-description { 282 margin-top: 0.75rem; 283 font-size: 0.875rem; 284 color: var(--text-secondary); 285 display: -webkit-box; 286 -webkit-line-clamp: 2; 287 -webkit-box-orient: vertical; 288 overflow: hidden; 289 } 290 291 .card-body { 292 padding: 1rem; 293 background: var(--bg-input); 294 flex: 1; 295 min-height: 0; 296 overflow-y: auto; 297 overflow-x: hidden; 298 } 299 300 .card-json { 301 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 302 font-size: 0.8125rem; 303 line-height: 1.6; 304 white-space: pre-wrap; 305 word-break: break-word; 306 } 307 308 /* Navigation Footer */ 309 .nav-footer { 310 display: flex; 311 align-items: center; 312 justify-content: center; 313 gap: 1rem; 314 margin-top: 1.5rem; 315 color: var(--text-secondary); 316 flex-shrink: 0; 317 } 318 319 .nav-btn { 320 display: flex; 321 align-items: center; 322 justify-content: center; 323 width: 2.5rem; 324 height: 2.5rem; 325 background: var(--bg-card); 326 border: 1px solid var(--border); 327 border-radius: 0.375rem; 328 color: var(--text-secondary); 329 cursor: pointer; 330 transition: all 0.15s; 331 } 332 333 .nav-btn:hover { 334 background: var(--bg-primary); 335 color: var(--text-primary); 336 border-color: var(--accent); 337 } 338 339 .nav-btn:disabled { 340 opacity: 0.4; 341 cursor: not-allowed; 342 } 343 344 .nav-position { 345 font-size: 0.875rem; 346 font-variant-numeric: tabular-nums; 347 min-width: 80px; 348 text-align: center; 349 } 350 351 .nav-hint { 352 text-align: center; 353 margin-top: 0.75rem; 354 font-size: 0.75rem; 355 color: var(--text-muted); 356 flex-shrink: 0; 357 } 358 359 /* View Transitions */ 360 @keyframes slide-out-left { 361 from { transform: translateX(0); opacity: 1; } 362 to { transform: translateX(-100px); opacity: 0; } 363 } 364 365 @keyframes slide-in-right { 366 from { transform: translateX(100px); opacity: 0; } 367 to { transform: translateX(0); opacity: 1; } 368 } 369 370 @keyframes slide-out-right { 371 from { transform: translateX(0); opacity: 1; } 372 to { transform: translateX(100px); opacity: 0; } 373 } 374 375 @keyframes slide-in-left { 376 from { transform: translateX(-100px); opacity: 0; } 377 to { transform: translateX(0); opacity: 1; } 378 } 379 380 ::view-transition-old(lexicon-card) { 381 animation: slide-out-left 200ms ease-out; 382 } 383 384 ::view-transition-new(lexicon-card) { 385 animation: slide-in-right 200ms ease-out; 386 } 387 388 html[data-direction="left"]::view-transition-old(lexicon-card) { 389 animation: slide-out-right 200ms ease-out; 390 } 391 392 html[data-direction="left"]::view-transition-new(lexicon-card) { 393 animation: slide-in-left 200ms ease-out; 394 } 395 396 /* Loading State */ 397 .loading-container { 398 display: flex; 399 flex-direction: column; 400 align-items: center; 401 justify-content: center; 402 padding: 4rem 2rem; 403 color: var(--text-secondary); 404 } 405 406 .loading-spinner { 407 width: 2rem; 408 height: 2rem; 409 border: 3px solid var(--border); 410 border-top-color: var(--accent); 411 border-radius: 50%; 412 animation: spin 0.8s linear infinite; 413 margin-bottom: 1rem; 414 } 415 416 @keyframes spin { 417 to { transform: rotate(360deg); } 418 } 419 420 /* Error State */ 421 .error-container { 422 text-align: center; 423 padding: 3rem 2rem; 424 background: var(--bg-card); 425 border: 1px solid var(--border); 426 border-radius: 0.5rem; 427 } 428 429 .error-icon { 430 font-size: 2rem; 431 margin-bottom: 0.5rem; 432 } 433 434 .error-message { 435 color: var(--error-text); 436 margin-bottom: 1rem; 437 } 438 439 .error-retry { 440 padding: 0.5rem 1rem; 441 background: var(--accent); 442 color: white; 443 border: none; 444 border-radius: 0.375rem; 445 cursor: pointer; 446 } 447 448 .error-retry:hover { 449 background: var(--accent-hover); 450 } 451 452 /* JSON Syntax Highlighting */ 453 .json-key { color: var(--json-key); } 454 .json-string { color: var(--json-string); } 455 .json-number { color: var(--json-number); } 456 .json-boolean { color: var(--json-boolean); } 457 .json-null { color: var(--json-null); } 458 .json-bracket { color: var(--json-bracket); } 459 460 .json-string-truncated { 461 cursor: pointer; 462 border-bottom: 1px dashed var(--json-string); 463 } 464 465 .json-string-truncated[data-expanded="true"] { 466 word-break: break-word; 467 } 468 469 .json-string-truncated:hover { 470 background: rgba(0, 102, 204, 0.1); 471 } 472 </style> 473 </head> 474 <body> 475 <div id="app"> 476 <header> 477 <h1> 478 <span style="color: var(--accent)">{</span> Lexicon Explorer 479 <span style="color: var(--accent)">}</span> 480 </h1> 481 <p class="tagline"> 482 Discover published <a href="https://atproto.com/specs/lexicon" target="_blank">ATProto lexicons</a> 483 </p> 484 </header> 485 <main id="main"></main> 486 </div> 487 <script> 488 // ============================================================================= 489 // CONFIGURATION 490 // ============================================================================= 491 492 const SERVER_URL = 'https://quickslice-production-4b57.up.railway.app'; 493 const GRAPHQL_ENDPOINT = `${SERVER_URL}/graphql`; 494 495 // ============================================================================= 496 // STATE 497 // ============================================================================= 498 499 const state = { 500 allNsids: [], // Shuffled list of all lexicon IDs 501 lexiconCache: new Map(), // Cache of fetched lexicon details 502 currentIndex: 0, // Position in shuffled list 503 currentLexicon: null, // Full lexicon data being displayed 504 searchQuery: '', // Current search input 505 searchResults: [], // Filtered NSIDs for autocomplete 506 selectedResultIndex: -1, // Which autocomplete result is highlighted 507 isLoading: true, 508 error: null, 509 direction: null // 'left' | 'right' for transition direction 510 }; 511 512 // ============================================================================= 513 // GRAPHQL HELPERS 514 // ============================================================================= 515 516 async function gqlQuery(query, variables = {}) { 517 const response = await fetch(GRAPHQL_ENDPOINT, { 518 method: 'POST', 519 headers: { 'Content-Type': 'application/json' }, 520 body: JSON.stringify({ query, variables }) 521 }); 522 523 if (!response.ok) { 524 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 525 } 526 527 const json = await response.json(); 528 if (json.errors) { 529 throw new Error(json.errors[0].message); 530 } 531 532 return json.data; 533 } 534 535 async function fetchAllNsids() { 536 const data = await gqlQuery(` 537 query GetAllNsids { 538 comAtprotoLexiconSchema(first: 1000) { 539 edges { 540 node { 541 id 542 } 543 } 544 } 545 } 546 `); 547 548 return data.comAtprotoLexiconSchema.edges.map(e => e.node.id); 549 } 550 551 async function fetchLexiconDetails(nsid) { 552 // Check cache first 553 if (state.lexiconCache.has(nsid)) { 554 return state.lexiconCache.get(nsid); 555 } 556 557 const data = await gqlQuery(` 558 query GetLexicon($nsid: String!) { 559 comAtprotoLexiconSchema(first: 1, where: { id: { eq: $nsid } }) { 560 edges { 561 node { 562 id 563 description 564 defs 565 actorHandle 566 indexedAt 567 } 568 } 569 } 570 } 571 `, { nsid }); 572 573 const lexicon = data.comAtprotoLexiconSchema.edges[0]?.node || null; 574 575 if (lexicon) { 576 state.lexiconCache.set(nsid, lexicon); 577 } 578 579 return lexicon; 580 } 581 582 // ============================================================================= 583 // UTILITIES 584 // ============================================================================= 585 586 function shuffleArray(array) { 587 const shuffled = [...array]; 588 for (let i = shuffled.length - 1; i > 0; i--) { 589 const j = Math.floor(Math.random() * (i + 1)); 590 [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 591 } 592 return shuffled; 593 } 594 595 function escapeHtml(str) { 596 const div = document.createElement('div'); 597 div.textContent = str || ''; 598 return div.innerHTML; 599 } 600 601 function getTruncateLength(indent = 0) { 602 // Account for indentation depth (2 spaces per level) 603 // Cap at card max-width (700px) 604 const cardWidth = Math.min(window.innerWidth, 700); 605 const isMobile = window.innerWidth < 600; 606 const charWidth = isMobile ? 12 : 10; 607 const padding = isMobile ? 120 : 140; 608 const indentPx = indent * (isMobile ? 24 : 20); 609 const available = cardWidth - padding - indentPx; 610 return Math.max(6, Math.floor(available / charWidth)); 611 } 612 613 function toggleString(element) { 614 const isExpanded = element.dataset.expanded === 'true'; 615 if (isExpanded) { 616 // Collapse: show truncated 617 element.textContent = element.dataset.truncated; 618 element.dataset.expanded = 'false'; 619 } else { 620 // Expand: show full 621 element.textContent = element.dataset.full; 622 element.dataset.expanded = 'true'; 623 } 624 } 625 626 function highlightJson(obj, indent = 0) { 627 const spaces = ' '.repeat(indent); 628 629 if (obj === null) { 630 return '<span class="json-null">null</span>'; 631 } 632 633 if (typeof obj === 'boolean') { 634 return `<span class="json-boolean">${obj}</span>`; 635 } 636 637 if (typeof obj === 'number') { 638 return `<span class="json-number">${obj}</span>`; 639 } 640 641 if (typeof obj === 'string') { 642 const truncateLen = getTruncateLength(indent); 643 if (obj.length > truncateLen) { 644 const truncatedText = `"${obj.slice(0, truncateLen)}…"`; 645 const fullText = `"${obj}"`; 646 // Escape for HTML attribute (double-encode quotes) 647 const truncatedAttr = truncatedText.replace(/&/g, '&amp;').replace(/"/g, '&quot;'); 648 const fullAttr = fullText.replace(/&/g, '&amp;').replace(/"/g, '&quot;'); 649 return `<span class="json-string json-string-truncated" data-expanded="false" data-truncated="${truncatedAttr}" data-full="${fullAttr}" onclick="toggleString(this)">${escapeHtml(truncatedText)}</span>`; 650 } 651 return `<span class="json-string">"${escapeHtml(obj)}"</span>`; 652 } 653 654 if (Array.isArray(obj)) { 655 if (obj.length === 0) { 656 return '<span class="json-bracket">[]</span>'; 657 } 658 659 const items = obj.map(item => 660 spaces + ' ' + highlightJson(item, indent + 1) 661 ).join(',\n'); 662 663 return '<span class="json-bracket">[</span>\n' + 664 items + '\n' + 665 spaces + '<span class="json-bracket">]</span>'; 666 } 667 668 if (typeof obj === 'object') { 669 const keys = Object.keys(obj); 670 if (keys.length === 0) { 671 return '<span class="json-bracket">{}</span>'; 672 } 673 674 const entries = keys.map(key => { 675 const value = highlightJson(obj[key], indent + 1); 676 return spaces + ' ' + 677 `<span class="json-key">"${escapeHtml(key)}"</span>: ${value}`; 678 }).join(',\n'); 679 680 return '<span class="json-bracket">{</span>\n' + 681 entries + '\n' + 682 spaces + '<span class="json-bracket">}</span>'; 683 } 684 685 return String(obj); 686 } 687 688 function getLexiconType(lexicon) { 689 if (!lexicon?.defs?.main?.type) return 'unknown'; 690 return lexicon.defs.main.type; 691 } 692 693 function parseNsidDomain(nsid) { 694 // NSID format: authority.name (e.g., com.atproto.repo.strongRef) 695 // Authority is reversed domain, typically first 2 segments 696 const parts = nsid.split('.'); 697 if (parts.length < 3) return { domain: null, rest: nsid }; 698 699 const domainParts = parts.slice(0, 2); 700 const rest = parts.slice(2).join('.'); 701 const reversedDomain = [...domainParts].reverse().join('.'); 702 703 return { 704 authority: domainParts.join('.'), 705 domain: reversedDomain, 706 rest: rest 707 }; 708 } 709 710 function formatLexiconJson(lexicon) { 711 // Reconstruct the full lexicon JSON structure 712 return { 713 lexicon: 1, 714 id: lexicon.id, 715 ...(lexicon.description && { description: lexicon.description }), 716 defs: lexicon.defs 717 }; 718 } 719 720 // ============================================================================= 721 // RENDERING 722 // ============================================================================= 723 724 function render() { 725 const main = document.getElementById('main'); 726 727 if (state.isLoading && state.allNsids.length === 0) { 728 main.innerHTML = ` 729 <div class="loading-container"> 730 <div class="loading-spinner"></div> 731 <div>Loading lexicons...</div> 732 </div> 733 `; 734 return; 735 } 736 737 if (state.error) { 738 main.innerHTML = ` 739 <div class="error-container"> 740 <div class="error-icon">✗</div> 741 <div class="error-message">${escapeHtml(state.error)}</div> 742 <button class="error-retry" onclick="init()">Retry</button> 743 </div> 744 `; 745 return; 746 } 747 748 const searchHtml = renderSearch(); 749 const cardHtml = state.currentLexicon ? renderCard() : ''; 750 const navHtml = renderNav(); 751 752 main.innerHTML = searchHtml + cardHtml + navHtml; 753 } 754 755 function renderSearch() { 756 const dropdownHtml = state.searchResults.length > 0 ? ` 757 <div class="search-dropdown"> 758 ${state.searchResults.slice(0, 8).map((nsid, i) => ` 759 <div class="search-result ${i === state.selectedResultIndex ? 'selected' : ''}" 760 data-index="${i}" 761 onclick="selectSearchResult(${i})"> 762 ${escapeHtml(nsid)} 763 </div> 764 `).join('')} 765 </div> 766 ` : ''; 767 768 return ` 769 <div class="search-container"> 770 <span class="search-icon">🔍</span> 771 <input type="text" 772 class="search-input" 773 placeholder="${window.innerWidth >= 600 ? 'Search lexicons... (/ to focus)' : 'Search lexicons...'}" 774 value="${escapeHtml(state.searchQuery)}" 775 oninput="handleSearchInput(this.value)" 776 onkeydown="handleSearchKeydown(event)" 777 onfocus="handleSearchFocus()" 778 onblur="handleSearchBlur(event)" /> 779 ${dropdownHtml} 780 </div> 781 `; 782 } 783 784 function renderCard() { 785 const lex = state.currentLexicon; 786 const type = getLexiconType(lex); 787 const fullJson = formatLexiconJson(lex); 788 const nsidParsed = parseNsidDomain(lex.id); 789 790 const descriptionHtml = lex.description 791 ? `<div class="card-description">${escapeHtml(lex.description)}</div>` 792 : ''; 793 794 const nsidHtml = nsidParsed.domain 795 ? `<a href="https://${escapeHtml(nsidParsed.domain)}" target="_blank" class="nsid-domain">${escapeHtml(nsidParsed.authority)}</a>.<wbr>${escapeHtml(nsidParsed.rest).replace(/\./g, '.<wbr>')}` 796 : escapeHtml(lex.id).replace(/\./g, '.<wbr>'); 797 798 return ` 799 <div class="lexicon-card"> 800 <div class="card-header"> 801 <div class="card-header-content"> 802 <div class="card-nsid">${nsidHtml}</div> 803 <div class="card-meta"> 804 <span class="card-meta-item"> 805 <span class="card-meta-label">Type:</span> ${escapeHtml(type)} 806 </span> 807 <span class="card-meta-item"> 808 <span class="card-meta-label">Author:</span> <a href="https://bsky.app/profile/${escapeHtml(lex.actorHandle || '')}" target="_blank" class="author-link">@${escapeHtml(lex.actorHandle || 'unknown')}</a> 809 </span> 810 </div> 811 ${descriptionHtml} 812 </div> 813 <div class="action-buttons"> 814 <button class="action-btn" onclick="copyLink()" title="Copy link">🔗</button> 815 <button class="action-btn" onclick="shareToBluesky()" title="Share on Bluesky">🦋</button> 816 </div> 817 </div> 818 <div class="card-body"> 819 <pre class="card-json">${highlightJson(fullJson)}</pre> 820 </div> 821 </div> 822 `; 823 } 824 825 function renderNav() { 826 const total = state.allNsids.length; 827 const current = state.currentIndex + 1; 828 829 return ` 830 <div class="nav-footer"> 831 <button class="nav-btn" onclick="navigate(-1)" ${state.currentIndex === 0 ? 'disabled' : ''}>←</button> 832 <span class="nav-position">${current} / ${total}</span> 833 <button class="nav-btn" onclick="navigate(1)" ${state.currentIndex === total - 1 ? 'disabled' : ''}>→</button> 834 </div> 835 <div class="nav-hint">← → keys or swipe to browse</div> 836 `; 837 } 838 839 // ============================================================================= 840 // NAVIGATION 841 // ============================================================================= 842 843 async function navigate(delta) { 844 const newIndex = state.currentIndex + delta; 845 846 if (newIndex < 0 || newIndex >= state.allNsids.length) { 847 return; 848 } 849 850 const direction = delta > 0 ? 'right' : 'left'; 851 await navigateToIndex(newIndex, direction); 852 } 853 854 async function navigateToIndex(index, direction = 'right') { 855 const nsid = state.allNsids[index]; 856 if (!nsid) return; 857 858 state.direction = direction; 859 document.documentElement.dataset.direction = direction; 860 861 // Add loading class for visual feedback 862 document.querySelector('.lexicon-card')?.classList.add('loading'); 863 864 const doUpdate = async () => { 865 const lexicon = await fetchLexiconDetails(nsid); 866 state.currentIndex = index; 867 state.currentLexicon = lexicon; 868 render(); 869 }; 870 871 if (document.startViewTransition) { 872 await document.startViewTransition(doUpdate).finished; 873 } else { 874 await doUpdate(); 875 } 876 877 // Update URL query param 878 const url = new URL(window.location); 879 url.searchParams.set('nsid', nsid); 880 history.replaceState(null, '', url); 881 882 delete document.documentElement.dataset.direction; 883 } 884 885 async function jumpToNsid(nsid) { 886 const index = state.allNsids.indexOf(nsid); 887 if (index === -1) return; 888 889 state.searchQuery = ''; 890 state.searchResults = []; 891 state.selectedResultIndex = -1; 892 893 await navigateToIndex(index, 'right'); 894 } 895 896 async function copyLink() { 897 const url = window.location.href; 898 await navigator.clipboard.writeText(url); 899 900 const btn = event.target; 901 if (btn) { 902 btn.textContent = '✓'; 903 btn.classList.add('copied'); 904 setTimeout(() => { 905 btn.textContent = '🔗'; 906 btn.classList.remove('copied'); 907 }, 1500); 908 } 909 } 910 911 function shareToBluesky() { 912 const url = window.location.href; 913 const nsid = state.currentLexicon?.id || ''; 914 const text = `Check out the ${nsid} lexicon\n\n${url}`; 915 const intentUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`; 916 window.open(intentUrl, '_blank'); 917 } 918 919 // ============================================================================= 920 // SEARCH 921 // ============================================================================= 922 923 function handleSearchInput(value) { 924 state.searchQuery = value; 925 state.selectedResultIndex = -1; 926 927 if (value.trim() === '') { 928 state.searchResults = []; 929 } else { 930 const query = value.toLowerCase(); 931 state.searchResults = state.allNsids.filter(nsid => 932 nsid.toLowerCase().includes(query) 933 ); 934 } 935 936 // Only update dropdown, don't re-render everything 937 updateSearchDropdown(); 938 } 939 940 function updateSearchDropdown() { 941 const container = document.querySelector('.search-container'); 942 if (!container) return; 943 944 // Remove existing dropdown 945 const existing = container.querySelector('.search-dropdown'); 946 if (existing) existing.remove(); 947 948 // Add new dropdown if there are results 949 if (state.searchResults.length > 0) { 950 const dropdown = document.createElement('div'); 951 dropdown.className = 'search-dropdown'; 952 dropdown.innerHTML = state.searchResults.slice(0, 8).map((nsid, i) => ` 953 <div class="search-result ${i === state.selectedResultIndex ? 'selected' : ''}" 954 data-index="${i}" 955 onclick="selectSearchResult(${i})"> 956 ${escapeHtml(nsid)} 957 </div> 958 `).join(''); 959 container.appendChild(dropdown); 960 } 961 } 962 963 function handleSearchFocus() { 964 if (state.searchQuery.trim() !== '') { 965 const query = state.searchQuery.toLowerCase(); 966 state.searchResults = state.allNsids.filter(nsid => 967 nsid.toLowerCase().includes(query) 968 ); 969 updateSearchDropdown(); 970 } 971 } 972 973 function handleSearchBlur(event) { 974 // Delay to allow click on dropdown to register 975 setTimeout(() => { 976 if (!document.activeElement?.classList.contains('search-input')) { 977 state.searchResults = []; 978 state.selectedResultIndex = -1; 979 updateSearchDropdown(); 980 } 981 }, 150); 982 } 983 984 function handleSearchKeydown(event) { 985 const results = state.searchResults; 986 987 if (event.key === 'ArrowDown') { 988 event.preventDefault(); 989 if (results.length > 0) { 990 state.selectedResultIndex = Math.min( 991 state.selectedResultIndex + 1, 992 Math.min(results.length - 1, 7) 993 ); 994 updateSearchDropdown(); 995 } 996 } else if (event.key === 'ArrowUp') { 997 event.preventDefault(); 998 if (results.length > 0) { 999 state.selectedResultIndex = Math.max(state.selectedResultIndex - 1, 0); 1000 updateSearchDropdown(); 1001 } 1002 } else if (event.key === 'Enter') { 1003 event.preventDefault(); 1004 if (state.selectedResultIndex >= 0 && results[state.selectedResultIndex]) { 1005 selectSearchResult(state.selectedResultIndex); 1006 } else if (results.length > 0) { 1007 selectSearchResult(0); 1008 } 1009 } else if (event.key === 'Escape') { 1010 event.preventDefault(); 1011 const input = document.querySelector('.search-input'); 1012 if (input) input.value = ''; 1013 state.searchQuery = ''; 1014 state.searchResults = []; 1015 state.selectedResultIndex = -1; 1016 updateSearchDropdown(); 1017 document.querySelector('.search-input')?.blur(); 1018 } 1019 } 1020 1021 function selectSearchResult(index) { 1022 const nsid = state.searchResults[index]; 1023 if (nsid) { 1024 jumpToNsid(nsid); 1025 } 1026 } 1027 1028 // ============================================================================= 1029 // TOUCH/SWIPE HANDLING 1030 // ============================================================================= 1031 1032 let touchStartX = 0; 1033 let touchStartY = 0; 1034 let touchEndX = 0; 1035 let touchEndY = 0; 1036 1037 function handleTouchStart(event) { 1038 touchStartX = event.changedTouches[0].screenX; 1039 touchStartY = event.changedTouches[0].screenY; 1040 } 1041 1042 function handleTouchEnd(event) { 1043 touchEndX = event.changedTouches[0].screenX; 1044 touchEndY = event.changedTouches[0].screenY; 1045 handleSwipe(); 1046 } 1047 1048 function handleSwipe() { 1049 const deltaX = touchEndX - touchStartX; 1050 const deltaY = touchEndY - touchStartY; 1051 const minSwipeDistance = 50; 1052 1053 // Only trigger if horizontal swipe is greater than vertical (not scrolling) 1054 if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > minSwipeDistance) { 1055 if (deltaX > 0) { 1056 // Swipe right = go to previous 1057 navigate(-1); 1058 } else { 1059 // Swipe left = go to next 1060 navigate(1); 1061 } 1062 } 1063 } 1064 1065 document.addEventListener('touchstart', handleTouchStart, { passive: true }); 1066 document.addEventListener('touchend', handleTouchEnd, { passive: true }); 1067 1068 // ============================================================================= 1069 // KEYBOARD HANDLING 1070 // ============================================================================= 1071 1072 function handleGlobalKeydown(event) { 1073 // Don't handle if typing in search 1074 const searchInput = document.querySelector('.search-input'); 1075 if (document.activeElement === searchInput) { 1076 return; 1077 } 1078 1079 if (event.key === 'ArrowLeft') { 1080 event.preventDefault(); 1081 navigate(-1); 1082 } else if (event.key === 'ArrowRight') { 1083 event.preventDefault(); 1084 navigate(1); 1085 } else if (event.key === '/') { 1086 event.preventDefault(); 1087 searchInput?.focus(); 1088 } 1089 } 1090 1091 document.addEventListener('keydown', handleGlobalKeydown); 1092 1093 // ============================================================================= 1094 // INITIALIZATION 1095 // ============================================================================= 1096 1097 async function init() { 1098 state.isLoading = true; 1099 state.error = null; 1100 render(); 1101 1102 try { 1103 const nsids = await fetchAllNsids(); 1104 state.allNsids = shuffleArray(nsids); 1105 1106 // Check for nsid in URL query params 1107 const params = new URLSearchParams(window.location.search); 1108 const urlNsid = params.get('nsid'); 1109 1110 if (urlNsid) { 1111 // Try to find the NSID in the list 1112 const index = state.allNsids.indexOf(urlNsid); 1113 if (index !== -1) { 1114 state.currentIndex = index; 1115 } else { 1116 // NSID not in list, add it to the front 1117 state.allNsids.unshift(urlNsid); 1118 state.currentIndex = 0; 1119 } 1120 const lexicon = await fetchLexiconDetails(urlNsid); 1121 state.currentLexicon = lexicon; 1122 } else if (state.allNsids.length > 0) { 1123 const firstLexicon = await fetchLexiconDetails(state.allNsids[0]); 1124 state.currentLexicon = firstLexicon; 1125 state.currentIndex = 0; 1126 // Set initial URL param 1127 const url = new URL(window.location); 1128 url.searchParams.set('nsid', state.allNsids[0]); 1129 history.replaceState(null, '', url); 1130 } 1131 1132 state.isLoading = false; 1133 render(); 1134 } catch (err) { 1135 console.error('Failed to initialize:', err); 1136 state.isLoading = false; 1137 state.error = 'Failed to load lexicons. Check your connection.'; 1138 render(); 1139 } 1140 } 1141 1142 // Start the app 1143 init(); 1144 </script> 1145 </body> 1146</html>