Tools for the Atmosphere
tools.slices.network
quickslice
atproto
html
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, '&').replace(/"/g, '"');
648 const fullAttr = fullText.replace(/&/g, '&').replace(/"/g, '"');
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>