Tools for the Atmosphere tools.slices.network
quickslice atproto html
at main 2233 lines 71 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 <meta 7 http-equiv="Content-Security-Policy" 8 content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://quickslice-production-4b57.up.railway.app wss://quickslice-production-4b57.up.railway.app https://public.api.bsky.app https://cloudflare-dns.com; img-src 'self' https: data:;" 9 /> 10 <title>{ Lexicon Publisher }</title> 11 <style> 12 /* CSS Reset */ 13 *, 14 *::before, 15 *::after { 16 box-sizing: border-box; 17 } 18 * { 19 margin: 0; 20 } 21 body { 22 line-height: 1.5; 23 -webkit-font-smoothing: antialiased; 24 } 25 input, 26 button, 27 textarea { 28 font: inherit; 29 } 30 31 /* Light Theme */ 32 :root { 33 --bg-primary: #f5f5f5; 34 --bg-card: #ffffff; 35 --bg-input: #fafafa; 36 --text-primary: #1a1a1a; 37 --text-secondary: #666666; 38 --accent: #0066cc; 39 --accent-hover: #0052a3; 40 --border: #e0e0e0; 41 --border-focus: #0066cc; 42 --error-bg: #fef2f2; 43 --error-border: #fca5a5; 44 --error-text: #dc2626; 45 --success-bg: #f0fdf4; 46 --success-border: #86efac; 47 --success-text: #16a34a; 48 } 49 50 body { 51 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 52 background: var(--bg-primary); 53 color: var(--text-primary); 54 min-height: 100vh; 55 padding: 2rem 1rem; 56 } 57 58 #app { 59 max-width: 700px; 60 margin: 0 auto; 61 } 62 63 header { 64 text-align: center; 65 margin-bottom: 2rem; 66 } 67 68 header h1 { 69 font-size: 2rem; 70 color: var(--text-primary); 71 margin-bottom: 0.25rem; 72 } 73 74 .tagline { 75 color: var(--text-secondary); 76 font-size: 0.875rem; 77 } 78 79 .tagline a { 80 color: var(--accent); 81 text-decoration: none; 82 } 83 84 .tagline a:hover { 85 text-decoration: underline; 86 } 87 88 a { 89 color: var(--accent); 90 } 91 92 a:visited { 93 color: var(--accent); 94 } 95 96 /* Actor Autocomplete */ 97 qs-actor-autocomplete { 98 --qs-input-bg: var(--bg-input); 99 --qs-input-border: var(--border); 100 --qs-input-border-focus: var(--accent); 101 --qs-input-text: var(--text-primary); 102 --qs-input-placeholder: var(--text-secondary); 103 --qs-input-padding: 0.5rem 0.75rem; 104 --qs-menu-bg: var(--bg-card); 105 --qs-menu-border: var(--border); 106 --qs-menu-shadow: rgba(0, 0, 0, 0.3); 107 --qs-item-hover: var(--bg-input); 108 --qs-item-text: var(--text-primary); 109 --qs-item-secondary: var(--text-secondary); 110 flex: 1; 111 } 112 113 /* Sections */ 114 .section { 115 background: var(--bg-card); 116 border-radius: 0.5rem; 117 padding: 1rem; 118 margin-bottom: 1rem; 119 border: 1px solid var(--border); 120 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); 121 } 122 123 .section-header { 124 display: flex; 125 justify-content: space-between; 126 align-items: center; 127 margin-bottom: 0.75rem; 128 } 129 130 .section-title { 131 font-weight: 600; 132 font-size: 0.875rem; 133 text-transform: uppercase; 134 letter-spacing: 0.05em; 135 color: var(--text-secondary); 136 } 137 138 /* Buttons */ 139 .btn { 140 padding: 0.5rem 1rem; 141 border: none; 142 border-radius: 0.375rem; 143 font-size: 0.875rem; 144 font-weight: 500; 145 cursor: pointer; 146 transition: 147 background-color 0.15s, 148 opacity 0.15s; 149 } 150 151 .btn-primary { 152 background: var(--accent); 153 color: #ffffff; 154 } 155 156 .btn-primary:hover { 157 background: var(--accent-hover); 158 } 159 160 .btn-secondary { 161 background: var(--bg-card); 162 color: var(--text-primary); 163 border: 1px solid var(--border); 164 } 165 166 .btn-secondary:hover { 167 background: var(--bg-primary); 168 } 169 170 .btn-small { 171 padding: 0.25rem 0.5rem; 172 font-size: 0.75rem; 173 } 174 175 .btn-danger { 176 background: var(--error-bg); 177 color: var(--error-text); 178 border: 1px solid var(--error-border); 179 } 180 181 .btn-danger:hover { 182 background: #fee2e2; 183 } 184 185 .btn:disabled { 186 opacity: 0.5; 187 cursor: not-allowed; 188 } 189 190 /* Editors */ 191 .editor-card { 192 background: var(--bg-input); 193 border: 1px solid var(--border); 194 border-radius: 0.375rem; 195 margin-bottom: 0.75rem; 196 } 197 198 .editor-header { 199 display: flex; 200 justify-content: space-between; 201 align-items: center; 202 padding: 0.5rem 0.75rem; 203 border-bottom: 1px solid var(--border); 204 background: var(--bg-card); 205 border-radius: 0.375rem 0.375rem 0 0; 206 } 207 208 .editor-label { 209 font-size: 0.75rem; 210 color: var(--text-secondary); 211 font-weight: 500; 212 } 213 214 textarea { 215 width: 100%; 216 min-height: 200px; 217 padding: 0.75rem; 218 background: var(--bg-input); 219 border: none; 220 color: var(--text-primary); 221 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 222 font-size: 0.8125rem; 223 resize: vertical; 224 border-radius: 0 0 0.375rem 0.375rem; 225 } 226 227 textarea:focus { 228 outline: none; 229 background: #ffffff; 230 } 231 232 textarea::placeholder { 233 color: var(--text-secondary); 234 opacity: 0.6; 235 } 236 237 input[type="text"] { 238 flex: 1; 239 padding: 0.5rem 0.75rem; 240 background: var(--bg-input); 241 border: 1px solid var(--border); 242 border-radius: 0.375rem; 243 color: var(--text-primary); 244 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 245 font-size: 0.8125rem; 246 } 247 248 input[type="text"]:focus { 249 outline: none; 250 border-color: var(--border-focus); 251 background: #ffffff; 252 } 253 254 input[type="text"]::placeholder { 255 color: var(--text-secondary); 256 opacity: 0.6; 257 } 258 259 /* Results */ 260 .result { 261 padding: 1rem; 262 border-radius: 0.375rem; 263 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 264 font-size: 0.8125rem; 265 white-space: pre-wrap; 266 word-break: break-word; 267 } 268 269 .result-success { 270 background: var(--success-bg); 271 border: 1px solid var(--success-border); 272 color: var(--success-text); 273 } 274 275 .result-error { 276 background: var(--error-bg); 277 border: 1px solid var(--error-border); 278 color: var(--error-text); 279 } 280 281 .result:empty { 282 display: none; 283 } 284 285 /* Button row */ 286 .button-row { 287 display: flex; 288 gap: 0.5rem; 289 margin-top: 0.75rem; 290 } 291 292 .hidden { 293 display: none !important; 294 } 295 296 /* Drop Zone */ 297 .drop-zone { 298 border: 2px dashed var(--border); 299 border-radius: 0.5rem; 300 padding: 2rem; 301 text-align: center; 302 margin-bottom: 1rem; 303 transition: all 0.2s ease; 304 cursor: pointer; 305 } 306 307 .drop-zone:hover { 308 border-color: var(--accent); 309 background: var(--bg-input); 310 } 311 312 .drop-zone.drag-over { 313 border-color: var(--accent); 314 background: rgba(0, 102, 204, 0.05); 315 } 316 317 .drop-zone-icon { 318 font-size: 2rem; 319 margin-bottom: 0.5rem; 320 } 321 322 .drop-zone-text { 323 color: var(--text-secondary); 324 font-size: 0.875rem; 325 } 326 327 .drop-zone-text strong { 328 color: var(--accent); 329 } 330 331 .drop-zone-hint { 332 color: var(--text-secondary); 333 font-size: 0.75rem; 334 margin-top: 0.25rem; 335 opacity: 0.8; 336 } 337 338 .drop-zone-example { 339 color: var(--text-secondary); 340 font-size: 0.7rem; 341 margin-top: 0.5rem; 342 opacity: 0.7; 343 } 344 345 .drop-zone-example pre { 346 font-family: monospace; 347 font-size: 0.65rem; 348 line-height: 1.3; 349 } 350 351 .drop-zone-example a { 352 color: var(--text-secondary); 353 } 354 355 .loading-spinner { 356 display: inline-block; 357 width: 1rem; 358 height: 1rem; 359 border: 2px solid var(--border); 360 border-top-color: var(--accent); 361 border-radius: 50%; 362 animation: spin 0.8s linear infinite; 363 margin-right: 0.5rem; 364 vertical-align: middle; 365 } 366 367 @keyframes spin { 368 to { 369 transform: rotate(360deg); 370 } 371 } 372 373 /* Collapsible editors */ 374 .editor-card.collapsed textarea { 375 display: none; 376 } 377 378 .collapse-toggle { 379 background: none; 380 border: none; 381 cursor: pointer; 382 padding: 0.25rem; 383 color: var(--text-secondary); 384 font-size: 0.75rem; 385 display: flex; 386 align-items: center; 387 gap: 0.25rem; 388 } 389 390 .collapse-toggle:hover { 391 color: var(--text-primary); 392 } 393 394 .collapse-all-row { 395 display: flex; 396 justify-content: flex-end; 397 margin-bottom: 0.5rem; 398 gap: 0.5rem; 399 } 400 401 /* Modal */ 402 .modal-overlay { 403 position: fixed; 404 top: 0; 405 left: 0; 406 right: 0; 407 bottom: 0; 408 background: rgba(0, 0, 0, 0.5); 409 display: flex; 410 align-items: center; 411 justify-content: center; 412 z-index: 1000; 413 } 414 415 .modal { 416 background: var(--bg-card); 417 border-radius: 0.5rem; 418 padding: 1.5rem; 419 width: 90%; 420 max-width: 400px; 421 box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); 422 } 423 424 .modal-header { 425 display: flex; 426 justify-content: space-between; 427 align-items: center; 428 margin-bottom: 1rem; 429 } 430 431 .modal-header h3 { 432 font-size: 1.125rem; 433 font-weight: 600; 434 } 435 436 .btn-close { 437 background: none; 438 border: none; 439 font-size: 1.5rem; 440 cursor: pointer; 441 color: var(--text-secondary); 442 line-height: 1; 443 } 444 445 .btn-close:hover { 446 color: var(--text-primary); 447 } 448 449 .form-group { 450 margin-bottom: 1rem; 451 } 452 453 .form-group label { 454 display: block; 455 font-size: 0.875rem; 456 font-weight: 500; 457 margin-bottom: 0.25rem; 458 } 459 460 .form-group input { 461 width: 100%; 462 padding: 0.5rem 0.75rem; 463 border: 1px solid var(--border); 464 border-radius: 0.375rem; 465 font-size: 0.875rem; 466 } 467 468 .form-group input:focus { 469 outline: none; 470 border-color: var(--border-focus); 471 } 472 473 /* User status in header */ 474 .user-status { 475 display: flex; 476 align-items: center; 477 gap: 0.75rem; 478 font-size: 0.875rem; 479 } 480 481 .user-handle { 482 color: var(--text-secondary); 483 } 484 485 .btn-text { 486 background: none; 487 border: none; 488 color: var(--accent); 489 cursor: pointer; 490 font-size: 0.875rem; 491 padding: 0; 492 } 493 494 .btn-text:hover { 495 text-decoration: underline; 496 } 497 498 /* Warning box */ 499 .warning-box { 500 background: #fef3c7; 501 border: 1px solid #fcd34d; 502 border-radius: 0.375rem; 503 padding: 0.75rem; 504 font-size: 0.875rem; 505 color: #92400e; 506 } 507 508 /* Published lexicon cards */ 509 .published-card { 510 background: var(--bg-input); 511 border: 1px solid var(--border); 512 border-radius: 0.375rem; 513 padding: 0.75rem; 514 margin-bottom: 0.5rem; 515 } 516 517 .published-header { 518 display: flex; 519 justify-content: space-between; 520 align-items: center; 521 } 522 523 .published-nsid { 524 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 525 font-size: 0.875rem; 526 font-weight: 500; 527 } 528 529 .published-meta { 530 font-size: 0.75rem; 531 color: var(--text-secondary); 532 margin-top: 0.25rem; 533 } 534 535 /* DNS instructions */ 536 .dns-card { 537 background: var(--bg-input); 538 border: 1px solid var(--border); 539 border-radius: 0.375rem; 540 padding: 1rem; 541 margin-bottom: 0.75rem; 542 } 543 544 .dns-domain { 545 font-weight: 600; 546 font-size: 1rem; 547 margin-bottom: 0.75rem; 548 color: var(--accent); 549 display: flex; 550 align-items: center; 551 gap: 0.5rem; 552 } 553 554 .dns-status { 555 font-size: 1.1rem; 556 font-weight: 700; 557 } 558 559 .dns-verified { 560 color: #22c55e; 561 } 562 563 .dns-not-found { 564 color: #ef4444; 565 } 566 567 .dns-card-verified { 568 border-color: #22c55e; 569 background: rgba(34, 197, 94, 0.05); 570 } 571 572 .dns-record { 573 background: var(--bg-card); 574 border: 1px solid var(--border); 575 border-radius: 0.25rem; 576 padding: 0.75rem; 577 margin-bottom: 0.75rem; 578 } 579 580 .dns-row { 581 display: flex; 582 align-items: center; 583 gap: 0.5rem; 584 margin-bottom: 0.25rem; 585 } 586 587 .dns-row:last-child { 588 margin-bottom: 0; 589 } 590 591 .dns-label { 592 font-size: 0.75rem; 593 color: var(--text-secondary); 594 width: 3rem; 595 } 596 597 .dns-value { 598 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 599 font-size: 0.8125rem; 600 flex: 1; 601 } 602 603 .dns-lexicons { 604 font-size: 0.875rem; 605 } 606 607 .dns-lexicons ul { 608 margin: 0.25rem 0 0 1.25rem; 609 padding: 0; 610 } 611 612 .dns-lexicons li { 613 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 614 font-size: 0.8125rem; 615 } 616 617 .dns-note { 618 background: #dbeafe; 619 border: 1px solid #93c5fd; 620 border-radius: 0.375rem; 621 padding: 0.75rem; 622 font-size: 0.875rem; 623 color: #1e40af; 624 } 625 626 /* Step wizard styles */ 627 .step { 628 background: var(--bg-card); 629 border-radius: 0.5rem; 630 margin-bottom: 0.75rem; 631 border: 1px solid var(--border); 632 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); 633 } 634 635 .step-header { 636 display: flex; 637 justify-content: space-between; 638 align-items: center; 639 padding: 1rem; 640 cursor: pointer; 641 user-select: none; 642 } 643 644 .step-header:hover { 645 background: var(--bg-input); 646 } 647 648 .step-header-left { 649 display: flex; 650 align-items: center; 651 gap: 0.75rem; 652 } 653 654 .step-number { 655 width: 1.75rem; 656 height: 1.75rem; 657 border-radius: 50%; 658 display: flex; 659 align-items: center; 660 justify-content: center; 661 font-size: 0.875rem; 662 font-weight: 600; 663 background: var(--border); 664 color: var(--text-secondary); 665 } 666 667 .step-title { 668 font-weight: 600; 669 font-size: 0.9375rem; 670 } 671 672 .step-summary { 673 font-size: 0.875rem; 674 color: var(--text-secondary); 675 } 676 677 .step-content { 678 padding: 1rem; 679 border-top: 1px solid var(--border); 680 } 681 682 /* Step states */ 683 .step.step-current .step-number { 684 background: var(--accent); 685 color: #ffffff; 686 } 687 688 .step.step-current .step-header { 689 cursor: default; 690 } 691 692 .step.step-current .step-header:hover { 693 background: transparent; 694 } 695 696 .step.step-completed .step-number { 697 background: var(--success-text); 698 color: #ffffff; 699 } 700 701 .step.step-completed .step-number::after { 702 content: '✓'; 703 } 704 705 .step.step-completed .step-number span { 706 display: none; 707 } 708 709 .step.step-future { 710 opacity: 0.5; 711 } 712 713 .step.step-future .step-header { 714 cursor: not-allowed; 715 } 716 717 .step.step-future .step-header:hover { 718 background: transparent; 719 } 720 721 .step.step-collapsed .step-content { 722 display: none; 723 } 724 725 .step.step-collapsed .step-header { 726 border-bottom: none; 727 } 728 </style> 729 </head> 730 <body> 731 <div id="app"> 732 <header> 733 <div> 734 <h1> 735 <span style="color: var(--accent)">{</span> Lexicon Publisher 736 <span style="color: var(--accent)">}</span> 737 </h1> 738 <p class="tagline"> 739 Publish your AT Protocol Lexicon schemas 740 </p> 741 </div> 742 </header> 743 <div style="text-align: center; margin-bottom: 1.5rem; font-size: 0.875rem; color: var(--text-secondary);"> 744 Learn more: 745 <a href="https://atproto.com/specs/lexicon#lexicon-publication-and-resolution" target="_blank" rel="noopener">AT Protocol Spec</a> 746 · 747 <a href="https://nickthesick.com/blog/Publishing+ATProto+Lexicons" target="_blank" rel="noopener">Publishing Guide</a> 748 · 749 🐛 <a href="https://tools.slices.network/bugs?ns=network.slices" target="_blank" rel="noopener">Report a bug</a> 750 </div> 751 <main id="steps-container"> 752 </main> 753 <footer style="text-align: center; padding: 2rem 1rem; color: var(--text-secondary); font-size: 0.875rem;"> 754 <svg width="24" height="24" viewBox="0 0 128 128" style="vertical-align: middle; margin-right: 0.5rem;"> 755 <defs> 756 <linearGradient id="board1" x1="0%" y1="0%" x2="100%" y2="100%"> 757 <stop offset="0%" stop-color="#FF6347" /> 758 <stop offset="100%" stop-color="#FF4500" /> 759 </linearGradient> 760 <linearGradient id="board2" x1="0%" y1="0%" x2="100%" y2="100%"> 761 <stop offset="0%" stop-color="#00CED1" /> 762 <stop offset="100%" stop-color="#4682B4" /> 763 </linearGradient> 764 </defs> 765 <g transform="translate(64, 64)"> 766 <ellipse cx="0" cy="-28" rx="50" ry="20" fill="url(#board1)" /> 767 <ellipse cx="0" cy="0" rx="60" ry="20" fill="url(#board2)" /> 768 <ellipse cx="0" cy="28" rx="40" ry="20" fill="#32CD32" /> 769 </g> 770 </svg> 771 © 2025 slices.network 772 </footer> 773 </div> 774 <script src="https://cdn.jsdelivr.net/gh/bigmoves/honk@deaa420/dist/honk.min.js" integrity="sha384-c6ZE86yow2uS6DWDABjcuBgp4d9kDleBTyI7eDxjwdgqvl92jM4iFZ+mK3FuxKTk" crossorigin="anonymous"></script> 775 <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js" integrity="sha384-+mbV2IY1Zk/X1p/nWllGySJSUN8uMs+gUAN10Or95UBH0fpj6GfKgPmgC5EXieXG" crossorigin="anonymous"></script> 776 <!-- TODO: Pin quickslice to a specific version/commit instead of @main --> 777 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js" crossorigin="anonymous"></script> 778 <script src="https://cdn.jsdelivr.net/gh/bigmoves/elements@v0.1.0/dist/elements.min.js" integrity="sha384-ZT1CwqnY+9K7mM18To0R9U17zu1pEQUJLncM59n3QlwKcGeHI944R8knY+Alh2eA" crossorigin="anonymous"></script> 779 <script> 780 // ============================================================================= 781 // CONSTANTS 782 // ============================================================================= 783 784 const SERVER_URL = "https://quickslice-production-4b57.up.railway.app"; 785 const CLIENT_ID = "client_0ygtMYHz-rzUtCMgvhigcA"; 786 const SCOPE = "atproto repo:com.atproto.lexicon.schema"; 787 788 // ============================================================================= 789 // STATE 790 // ============================================================================= 791 792 const state = { 793 // Existing lexicon state 794 lexicons: [{ id: 1, value: "", collapsed: false }], 795 nextLexiconId: 2, 796 result: null, 797 798 // New: Auth state 799 client: null, 800 viewer: null, // { did, handle } 801 802 // New: Published lexicons from user's PDS 803 publishedLexicons: [], 804 805 // New: Publish workflow 806 validationPassed: false, 807 conflicts: [], // [{ nsid, existingOwner, isSameOwner }] 808 publishResults: [], // [{ nsid, success, error }] 809 810 // New: DNS instructions 811 dnsInstructions: [], // [{ domain, record, lexicons }] 812 813 // New: External dependency check 814 missingDependencies: [], // [nsid, ...] 815 816 // Publish result (separate from validation result) 817 publishResult: null, 818 819 // Loading states 820 isValidating: false, 821 isPublishing: false, 822 823 // Step wizard state 824 currentStep: 1, // 1=SignIn, 2=Define, 3=Publish, 4=DNS, 5=Published 825 stepCompleted: { 826 1: false, // Sign In 827 2: false, // Define Lexicons 828 3: false, // Publish 829 4: false, // Verify DNS (non-blocking) 830 }, 831 }; 832 833 // ============================================================================= 834 // STEP NAVIGATION 835 // ============================================================================= 836 837 function goToStep(stepNumber) { 838 if (stepNumber < 1 || stepNumber > 5) return; 839 840 // Can only go to completed steps or the next available step 841 const maxAllowedStep = getMaxAllowedStep(); 842 if (stepNumber > maxAllowedStep) return; 843 844 state.currentStep = stepNumber; 845 renderAllSteps(); 846 } 847 848 function getMaxAllowedStep() { 849 // Can go to step N+1 if step N is completed 850 for (let i = 1; i <= 4; i++) { 851 if (!state.stepCompleted[i]) return i; 852 } 853 return 5; 854 } 855 856 function completeStep(stepNumber) { 857 state.stepCompleted[stepNumber] = true; 858 state.currentStep = Math.min(stepNumber + 1, 5); 859 renderAllSteps(); 860 } 861 862 function resetStepsFrom(stepNumber) { 863 // Reset this step and all downstream steps 864 for (let i = stepNumber; i <= 4; i++) { 865 state.stepCompleted[i] = false; 866 } 867 // Clear downstream state 868 if (stepNumber <= 2) { 869 state.validationPassed = false; 870 state.conflicts = []; 871 state.missingDependencies = []; 872 state.result = null; 873 } 874 if (stepNumber <= 3) { 875 state.publishResult = null; 876 state.publishResults = []; 877 } 878 } 879 880 function getStepSummary(stepNumber) { 881 switch (stepNumber) { 882 case 1: 883 return state.viewer ? `@${state.viewer.handle}` : ''; 884 case 2: 885 const validCount = state.lexicons.filter(l => l.value.trim()).length; 886 return `${validCount} lexicon${validCount !== 1 ? 's' : ''} ready`; 887 case 3: 888 const publishedCount = state.publishResults.filter(r => r.success).length; 889 return `Published ${publishedCount} lexicon${publishedCount !== 1 ? 's' : ''}`; 890 case 4: 891 const verifiedCount = state.dnsInstructions.filter(d => d.verified === true).length; 892 const totalDns = state.dnsInstructions.length; 893 if (totalDns === 0) return 'No DNS needed'; 894 if (verifiedCount === totalDns) return 'DNS verified'; 895 return `${verifiedCount}/${totalDns} domains verified`; 896 default: 897 return ''; 898 } 899 } 900 901 // ============================================================================= 902 // PLACEHOLDERS 903 // ============================================================================= 904 905 const LEXICON_PLACEHOLDER = `{ 906 "lexicon": 1, 907 "id": "com.example.myRecord", 908 "defs": { 909 "main": { 910 "type": "record", 911 "record": { 912 "type": "object", 913 "required": ["status", "createdAt"], 914 "properties": { 915 "status": { 916 "type": "string", 917 "maxLength": 100 918 }, 919 "createdAt": { 920 "type": "string", 921 "format": "datetime" 922 } 923 } 924 } 925 } 926 } 927}`; 928 929 // ============================================================================= 930 // HELPERS 931 // ============================================================================= 932 933 function esc(str) { 934 const d = document.createElement("div"); 935 d.textContent = str || ""; 936 return d.innerHTML; 937 } 938 939 function escapeAttr(str) { 940 return (str || "") 941 .replace(/&/g, "&amp;") 942 .replace(/"/g, "&quot;") 943 .replace(/</g, "&lt;") 944 .replace(/>/g, "&gt;"); 945 } 946 947 function unwrapHonkResult(result) { 948 if (typeof result.isOk === "function") { 949 return { ok: result.isOk(), value: result[0] }; 950 } 951 return { ok: true, value: result }; 952 } 953 954 function formatHonkError(err) { 955 if (!err) return "Unknown error"; 956 if (typeof err === "string") return err; 957 if (err.message) { 958 return err.path ? `${err.message} (at ${err.path})` : err.message; 959 } 960 961 // Handle Gleam Dict/Map structure (HAMT) 962 // Structure: { root: { array: [{ k: nsid, v: { head: errorMsg, tail: {} } }] }, size: n } 963 if (err.root && err.root.array && Array.isArray(err.root.array)) { 964 const errors = []; 965 for (const entry of err.root.array) { 966 if (entry.v) { 967 // Extract errors from linked list structure (head/tail) 968 let current = entry.v; 969 while (current && current.head) { 970 errors.push(current.head); 971 current = current.tail; 972 } 973 } 974 } 975 if (errors.length > 0) { 976 return errors.join("\n"); 977 } 978 } 979 980 // Handle simple linked list structure (head/tail without root) 981 if (err.head) { 982 const errors = []; 983 let current = err; 984 while (current && current.head) { 985 errors.push(current.head); 986 current = current.tail; 987 } 988 if (errors.length > 0) { 989 return errors.join("\n"); 990 } 991 } 992 993 return JSON.stringify(err, null, 2); 994 } 995 996 // ============================================================================= 997 // OAUTH 998 // ============================================================================= 999 1000 async function initClient() { 1001 if (!state.client) { 1002 state.client = await QuicksliceClient.createQuicksliceClient({ 1003 server: SERVER_URL, 1004 clientId: CLIENT_ID, 1005 scope: SCOPE, 1006 }); 1007 } 1008 return state.client; 1009 } 1010 1011 function showLoginModal() { 1012 const existing = document.getElementById("login-modal"); 1013 if (existing) existing.remove(); 1014 1015 const modal = document.createElement("div"); 1016 modal.id = "login-modal"; 1017 modal.className = "modal-overlay"; 1018 modal.innerHTML = ` 1019 <div class="modal"> 1020 <div class="modal-header"> 1021 <h3>Sign In</h3> 1022 <button class="btn-close" onclick="closeLoginModal()">&times;</button> 1023 </div> 1024 <form onsubmit="handleLogin(event)"> 1025 <div class="form-group"> 1026 <label for="login-handle">Your handle</label> 1027 <input type="text" id="login-handle" placeholder="you.bsky.social" required /> 1028 </div> 1029 <button type="submit" class="btn btn-primary" style="width: 100%">Sign In</button> 1030 </form> 1031 </div> 1032 `; 1033 document.body.appendChild(modal); 1034 document.getElementById("login-handle").focus(); 1035 } 1036 1037 function closeLoginModal() { 1038 const modal = document.getElementById("login-modal"); 1039 if (modal) modal.remove(); 1040 } 1041 1042 async function handleLogin(event) { 1043 event.preventDefault(); 1044 const handle = document.getElementById("login-handle").value.trim(); 1045 if (!handle) return; 1046 1047 try { 1048 await initClient(); 1049 await state.client.loginWithRedirect({ handle }); 1050 } catch (err) { 1051 console.error("Login error:", err); 1052 state.result = { success: false, message: "Login failed: " + err.message }; 1053 renderAllSteps(); 1054 } 1055 } 1056 1057 function handleLogout() { 1058 if (state.client) { 1059 state.client.logout(); 1060 } 1061 window.location.reload(); 1062 } 1063 1064 // ============================================================================= 1065 // RENDERING 1066 // ============================================================================= 1067 1068 function renderStep(stepNumber, title, contentHtml) { 1069 const isCompleted = state.stepCompleted[stepNumber]; 1070 const isCurrent = state.currentStep === stepNumber; 1071 const isFuture = stepNumber > getMaxAllowedStep(); 1072 const isCollapsed = !isCurrent; 1073 1074 const classes = ['step']; 1075 if (isCompleted) classes.push('step-completed'); 1076 if (isCurrent) classes.push('step-current'); 1077 if (isFuture) classes.push('step-future'); 1078 if (isCollapsed) classes.push('step-collapsed'); 1079 1080 const summary = isCompleted ? getStepSummary(stepNumber) : ''; 1081 const clickHandler = !isCurrent && !isFuture ? `onclick="goToStep(${stepNumber})"` : ''; 1082 1083 return ` 1084 <div class="${classes.join(' ')}" data-step="${stepNumber}"> 1085 <div class="step-header" ${clickHandler}> 1086 <div class="step-header-left"> 1087 <div class="step-number"><span>${stepNumber}</span></div> 1088 <span class="step-title">${esc(title)}</span> 1089 </div> 1090 ${summary ? `<span class="step-summary">${esc(summary)}</span>` : ''} 1091 </div> 1092 <div class="step-content"> 1093 ${contentHtml} 1094 </div> 1095 </div> 1096 `; 1097 } 1098 1099 function renderAllSteps() { 1100 const container = document.getElementById('steps-container'); 1101 if (!container) return; 1102 1103 container.innerHTML = [ 1104 renderStep(1, 'Sign In', renderSignInContent()), 1105 renderStep(2, 'Define Lexicons', renderDefineLexiconsContent()), 1106 renderStep(3, 'Publish', renderPublishContent()), 1107 renderStep(4, 'Verify DNS', renderDnsContent()), 1108 renderStep(5, 'Your Published Lexicons', renderPublishedLexiconsContent()), 1109 ].join(''); 1110 1111 setupAutocomplete(); 1112 } 1113 1114 function renderSignInContent() { 1115 if (state.viewer) { 1116 // Already signed in - show minimal confirmation 1117 return ` 1118 <p style="color: var(--text-secondary);"> 1119 Signed in as <strong>@${esc(state.viewer.handle)}</strong> 1120 </p> 1121 <div class="button-row"> 1122 <button class="btn btn-secondary" onclick="handleLogout()">Sign Out</button> 1123 </div> 1124 `; 1125 } 1126 1127 return ` 1128 <p style="color: var(--text-secondary); margin-bottom: 1rem;"> 1129 Sign in with your AT Protocol handle to publish lexicons to your PDS. 1130 </p> 1131 <form onsubmit="handleStepLogin(event)" style="display: flex; gap: 0.5rem; align-items: flex-start;"> 1132 <qs-actor-autocomplete id="handle-autocomplete" placeholder="you.bsky.social"></qs-actor-autocomplete> 1133 <input type="hidden" id="step-login-handle" /> 1134 <button type="submit" class="btn btn-primary">Sign In</button> 1135 </form> 1136 `; 1137 } 1138 1139 function setupAutocomplete() { 1140 const autocomplete = document.getElementById('handle-autocomplete'); 1141 if (!autocomplete) return; 1142 1143 autocomplete.addEventListener('qs-select', (e) => { 1144 document.getElementById('step-login-handle').value = e.detail.actor.handle; 1145 }); 1146 autocomplete.addEventListener('input', () => { 1147 document.getElementById('step-login-handle').value = autocomplete.value; 1148 }); 1149 } 1150 1151 async function handleStepLogin(event) { 1152 event.preventDefault(); 1153 const handle = document.getElementById('step-login-handle').value.trim() || 1154 document.getElementById('handle-autocomplete')?.value?.trim(); 1155 if (!handle) return; 1156 1157 try { 1158 await initClient(); 1159 await state.client.loginWithRedirect({ handle }); 1160 } catch (err) { 1161 console.error('Login error:', err); 1162 state.result = { success: false, message: 'Login failed: ' + err.message }; 1163 renderAllSteps(); 1164 } 1165 } 1166 1167 function renderDefineLexiconsContent() { 1168 const getLexiconLabel = (lex, idx) => { 1169 try { 1170 const obj = JSON.parse(lex.value); 1171 if (obj.id) return obj.id; 1172 } catch (e) {} 1173 return `Lexicon ${idx + 1}`; 1174 }; 1175 1176 const editorsHtml = state.lexicons 1177 .map( 1178 (lex, idx) => ` 1179 <div class="editor-card ${lex.collapsed ? "collapsed" : ""}" data-lexicon-id="${lex.id}"> 1180 <div class="editor-header"> 1181 <div style="display: flex; align-items: center; gap: 0.5rem;"> 1182 <button class="collapse-toggle" onclick="toggleLexicon(${lex.id})"> 1183 ${lex.collapsed ? "▶" : "▼"} 1184 </button> 1185 <span class="editor-label">${esc(getLexiconLabel(lex, idx))}</span> 1186 </div> 1187 <button 1188 class="btn btn-danger btn-small" 1189 onclick="removeLexicon(${lex.id})" 1190 ${state.lexicons.length === 1 ? "disabled" : ""} 1191 >Remove</button> 1192 </div> 1193 <textarea 1194 placeholder="${escapeAttr(LEXICON_PLACEHOLDER)}" 1195 onchange="handleLexiconChange(${lex.id}, this.value)" 1196 oninput="handleLexiconChange(${lex.id}, this.value)" 1197 >${esc(lex.value)}</textarea> 1198 </div> 1199 `, 1200 ) 1201 .join(""); 1202 1203 const hasMultiple = state.lexicons.length > 1; 1204 const allCollapsed = state.lexicons.every((l) => l.collapsed); 1205 const collapseAllRow = hasMultiple 1206 ? `<div class="collapse-all-row"> 1207 <button class="btn btn-secondary btn-small" onclick="toggleAllLexicons(${allCollapsed ? "false" : "true"})"> 1208 ${allCollapsed ? "Expand All" : "Collapse All"} 1209 </button> 1210 </div>` 1211 : ""; 1212 1213 const resultHtml = state.result && state.currentStep === 2 ? ` 1214 <div class="result ${state.result.success ? 'result-success' : 'result-error'}" style="margin-top: 0.75rem;">${state.result.success ? '✓' : '✗'} ${esc(state.result.message)}</div> 1215 ${state.missingDependencies.length > 0 ? ` 1216 <div class="warning-box" style="margin-top: 0.75rem;"> 1217 <strong>Missing external dependencies:</strong> 1218 <ul style="margin: 0.5rem 0 0 1.25rem;"> 1219 ${state.missingDependencies.map(d => `<li>${esc(d)}</li>`).join('')} 1220 </ul> 1221 </div> 1222 ` : ''} 1223 ` : ''; 1224 1225 return ` 1226 <div 1227 class="drop-zone" 1228 id="drop-zone" 1229 ondragover="handleDragOver(event)" 1230 ondragleave="handleDragLeave(event)" 1231 ondrop="handleDrop(event)" 1232 onclick="triggerFileInput()" 1233 > 1234 <div class="drop-zone-icon">📦</div> 1235 <div class="drop-zone-text"> 1236 <strong>Drop a .zip file of your lexicons</strong> or click to browse 1237 </div> 1238 <div class="drop-zone-hint">Supports nested folders with .json lexicon files</div> 1239 <div class="drop-zone-example"> 1240<pre style="text-align: left; margin: 0.75rem auto 0; display: inline-block;">lexicons/ 1241├── com/ 1242│ └── example/ 1243│ ├── actor/ 1244│ │ └── profile.json 1245│ └── feed/ 1246│ └── post.json</pre> 1247 </div> 1248 </div> 1249 <div style="display: flex; justify-content: center; margin-bottom: 1rem; color: var(--text-secondary);"> 1250 See&nbsp;<a href="https://github.com/bluesky-social/atproto/tree/main/lexicons" target="_blank" rel="noopener">bluesky-social/atproto/lexicons</a>&nbsp;for a typical structure 1251 </div> 1252 <input type="file" id="file-input" accept=".zip" onchange="handleFileSelect(event)" style="display: none;" /> 1253 ${collapseAllRow} 1254 ${editorsHtml} 1255 <div class="button-row"> 1256 <button class="btn btn-secondary" onclick="addLexicon()" ${state.isValidating ? 'disabled' : ''}>+ Add Lexicon</button> 1257 <button class="btn btn-primary" onclick="continueToPublish()" ${state.isValidating ? 'disabled' : ''}> 1258 ${state.isValidating ? '<span class="loading-spinner"></span> Validating...' : 'Continue to Publish'} 1259 </button> 1260 </div> 1261 ${state.publishedLexicons.length > 0 ? ` 1262 <div style="text-align: center; margin-top: 0.75rem;"> 1263 <a href="#" onclick="goToPublishedStep(); return false;" style="color: var(--text-secondary); font-size: 0.875rem;"> 1264 or view your published lexicons 1265 </a> 1266 </div> 1267 ` : ''} 1268 ${resultHtml} 1269 `; 1270 } 1271 1272 function goToPublishedStep() { 1273 // Skip to step 5 to view published lexicons 1274 state.stepCompleted[2] = true; 1275 state.stepCompleted[3] = true; 1276 state.stepCompleted[4] = true; 1277 state.currentStep = 5; 1278 renderAllSteps(); 1279 } 1280 1281 function handleLexiconChange(id, value) { 1282 const lex = state.lexicons.find((l) => l.id === id); 1283 if (lex) { 1284 lex.value = value; 1285 // Reset downstream steps when editing 1286 if (state.stepCompleted[2]) { 1287 resetStepsFrom(2); 1288 } 1289 } 1290 } 1291 1292 async function continueToPublish() { 1293 // Validate lexicons first 1294 state.result = null; 1295 state.validationPassed = false; 1296 state.conflicts = []; 1297 state.missingDependencies = []; 1298 state.isValidating = true; 1299 renderAllSteps(); 1300 1301 const parseResult = parseLexicons(); 1302 if (parseResult.error) { 1303 state.result = { success: false, message: parseResult.error }; 1304 state.isValidating = false; 1305 renderAllSteps(); 1306 return; 1307 } 1308 1309 try { 1310 const lexiconList = honk.toList(parseResult.lexicons); 1311 const result = honk.validate(lexiconList); 1312 const unwrapped = unwrapHonkResult(result); 1313 1314 if (!unwrapped.ok) { 1315 state.result = { 1316 success: false, 1317 message: `Validation failed: ${formatHonkError(unwrapped.value)}`, 1318 }; 1319 state.isValidating = false; 1320 renderAllSteps(); 1321 return; 1322 } 1323 1324 const count = parseResult.lexicons.length; 1325 state.result = { success: true, message: `${count} lexicon${count !== 1 ? 's' : ''} valid` }; 1326 state.validationPassed = true; 1327 1328 // Check external dependencies and conflicts 1329 if (state.viewer) { 1330 const userDomain = getUserDomain(state.viewer.handle); 1331 const externalRefs = extractExternalRefs(parseResult.lexicons, userDomain); 1332 const { missing } = await checkExternalDependencies(externalRefs); 1333 state.missingDependencies = missing; 1334 await checkConflicts(); 1335 } 1336 1337 state.isValidating = false; 1338 // Complete step 2 and move to step 3 1339 completeStep(2); 1340 } catch (e) { 1341 state.result = { 1342 success: false, 1343 message: `Validation error: ${e.message}`, 1344 }; 1345 state.isValidating = false; 1346 renderAllSteps(); 1347 } 1348 } 1349 1350 function renderPublishContent() { 1351 const { publishable, excluded } = getPublishableLexicons(); 1352 const userDomain = state.viewer ? getUserDomain(state.viewer.handle) : ''; 1353 1354 // Build conflict lookup 1355 const conflictMap = {}; 1356 for (const c of state.conflicts) { 1357 conflictMap[c.nsid] = c; 1358 } 1359 1360 // Categorize lexicons 1361 const willPublish = []; // new or update (same owner) 1362 const blocked = []; // different owner conflicts 1363 1364 for (const p of publishable) { 1365 const conflict = conflictMap[p.nsid]; 1366 if (conflict && !conflict.isSameOwner) { 1367 blocked.push({ nsid: p.nsid, owner: conflict.existingOwner }); 1368 } else { 1369 willPublish.push({ 1370 nsid: p.nsid, 1371 isUpdate: conflict && conflict.isSameOwner 1372 }); 1373 } 1374 } 1375 1376 const publishCount = willPublish.length; 1377 const canPublish = publishCount > 0; 1378 1379 const resultHtml = state.publishResult ? ` 1380 <div class="result ${state.publishResult.success ? 'result-success' : 'result-error'}" style="margin-top: 0.75rem;">${state.publishResult.success ? '✓' : '✗'} ${esc(state.publishResult.message)}</div> 1381 ` : ''; 1382 1383 // Unified lexicon list 1384 const listItems = []; 1385 1386 // Publishable (new or update) 1387 for (const item of willPublish) { 1388 const status = item.isUpdate ? 'update' : 'new'; 1389 listItems.push(`<li><span style="color: var(--success);">✓</span> ${esc(item.nsid)} <span style="opacity: 0.6;">(${status})</span></li>`); 1390 } 1391 1392 // Excluded (wrong domain) 1393 for (const item of excluded) { 1394 listItems.push(`<li><span style="opacity: 0.5;">−</span> <span style="opacity: 0.6;">${esc(item.nsid)}</span> <span style="opacity: 0.5;">(${esc(getRootDomain(item.domain))})</span></li>`); 1395 } 1396 1397 // Blocked (different owner) 1398 for (const item of blocked) { 1399 listItems.push(`<li><span style="color: var(--error);">✗</span> ${esc(item.nsid)} <span style="opacity: 0.6;">owned by @${esc(item.owner)}</span></li>`); 1400 } 1401 1402 const lexiconList = listItems.length > 0 ? ` 1403 <ul style="margin: 0 0 0.75rem 0; padding-left: 0; list-style: none; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.875rem;"> 1404 ${listItems.join('')} 1405 </ul> 1406 ` : ''; 1407 1408 return ` 1409 <p style="color: var(--text-secondary); margin-bottom: 0.75rem;"> 1410 ${canPublish 1411 ? `Publish ${publishCount} lexicon${publishCount !== 1 ? 's' : ''} to @${esc(state.viewer?.handle || '')}'s PDS.` 1412 : `No lexicons to publish for ${esc(userDomain)}.`} 1413 </p> 1414 ${lexiconList} 1415 <div class="button-row"> 1416 <button class="btn btn-primary" onclick="publishAndContinue()" ${canPublish && !state.isPublishing ? '' : 'disabled'}> 1417 ${state.isPublishing ? '<span class="loading-spinner"></span> Publishing...' : 'Publish to PDS'} 1418 </button> 1419 </div> 1420 ${resultHtml} 1421 `; 1422 } 1423 1424 async function publishAndContinue() { 1425 if (!state.viewer || !state.client) return; 1426 1427 state.publishResults = []; 1428 state.publishResult = null; 1429 state.isPublishing = true; 1430 renderAllSteps(); 1431 1432 const { publishable: lexiconsToPublish } = getPublishableLexicons(); 1433 1434 if (lexiconsToPublish.length === 0) { 1435 state.publishResult = { success: false, message: "No valid lexicons to publish" }; 1436 state.isPublishing = false; 1437 renderAllSteps(); 1438 return; 1439 } 1440 1441 // Publish each lexicon 1442 for (const { nsid, schema } of lexiconsToPublish) { 1443 try { 1444 const ownedConflict = state.conflicts.find(c => c.nsid === nsid && c.isSameOwner); 1445 const input = { 1446 id: nsid, 1447 defs: schema.defs, 1448 lexicon: schema.lexicon || 1, 1449 ...(schema.description && { description: schema.description }), 1450 }; 1451 1452 if (ownedConflict) { 1453 await state.client.mutate(` 1454 mutation UpdateSchema($input: ComAtprotoLexiconSchemaInput!, $rkey: String!) { 1455 updateComAtprotoLexiconSchema(input: $input, rkey: $rkey) { 1456 uri 1457 } 1458 } 1459 `, { rkey: nsid, input }); 1460 state.publishResults.push({ nsid, success: true, updated: true }); 1461 } else { 1462 await state.client.mutate(` 1463 mutation CreateSchema($input: ComAtprotoLexiconSchemaInput!, $rkey: String!) { 1464 createComAtprotoLexiconSchema(input: $input, rkey: $rkey) { 1465 uri 1466 } 1467 } 1468 `, { rkey: nsid, input }); 1469 state.publishResults.push({ nsid, success: true }); 1470 } 1471 } catch (err) { 1472 state.publishResults.push({ nsid, success: false, error: err.message }); 1473 } 1474 } 1475 1476 // Generate DNS instructions 1477 generateDnsInstructions(lexiconsToPublish.map(l => l.nsid)); 1478 1479 // Refresh published lexicons list 1480 await fetchPublishedLexicons(); 1481 1482 // Show results 1483 const successCount = state.publishResults.filter(r => r.success).length; 1484 const failCount = state.publishResults.filter(r => !r.success).length; 1485 1486 state.isPublishing = false; 1487 1488 if (failCount === 0) { 1489 state.publishResult = { success: true, message: `Published ${successCount} lexicon${successCount !== 1 ? 's' : ''}` }; 1490 // Complete step 3 and move forward 1491 completeStep(3); 1492 } else { 1493 const errors = state.publishResults.filter(r => !r.success).map(r => `${r.nsid}: ${r.error}`).join('\n'); 1494 state.publishResult = { success: false, message: `Published ${successCount}, failed ${failCount}:\n${errors}` }; 1495 renderAllSteps(); 1496 } 1497 } 1498 1499 function renderDnsContent() { 1500 if (state.dnsInstructions.length === 0) { 1501 return ` 1502 <p style="color: var(--text-secondary);"> 1503 No DNS records required. Your lexicons are published! 1504 </p> 1505 <div class="button-row"> 1506 <button class="btn btn-primary" onclick="skipDnsAndContinue()">View Published Lexicons</button> 1507 </div> 1508 `; 1509 } 1510 1511 const cards = state.dnsInstructions.map(instr => { 1512 let statusIcon = ''; 1513 if (instr.verified === true) { 1514 statusIcon = '<span class="dns-status dns-verified" title="Verified">✓</span>'; 1515 } else if (instr.verified === false) { 1516 statusIcon = '<span class="dns-status dns-not-found" title="Not found">✗</span>'; 1517 } 1518 1519 return ` 1520 <div class="dns-card ${instr.verified === true ? 'dns-card-verified' : ''}"> 1521 <div class="dns-domain">${esc(instr.domain)} ${statusIcon}</div> 1522 <div class="dns-record"> 1523 <div class="dns-row"> 1524 <span class="dns-label">Type:</span> 1525 <span class="dns-value">TXT</span> 1526 </div> 1527 <div class="dns-row"> 1528 <span class="dns-label">Name:</span> 1529 <span class="dns-value">${esc(instr.recordName)}</span> 1530 <button class="btn btn-secondary btn-small" onclick="copyToClipboard('${escapeAttr(instr.recordName)}')">Copy</button> 1531 </div> 1532 <div class="dns-row"> 1533 <span class="dns-label">Value:</span> 1534 <span class="dns-value">${esc(instr.recordValue)}</span> 1535 <button class="btn btn-secondary btn-small" onclick="copyToClipboard('${escapeAttr(instr.recordValue)}')">Copy</button> 1536 </div> 1537 </div> 1538 <div class="dns-lexicons"> 1539 <strong>Lexicons:</strong> 1540 <ul> 1541 ${instr.lexicons.map(l => `<li>${esc(l)}</li>`).join('')} 1542 </ul> 1543 </div> 1544 </div> 1545 `; 1546 }).join(''); 1547 1548 const allVerified = state.dnsInstructions.every(d => d.verified === true); 1549 1550 return ` 1551 <div class="dns-note" style="margin-bottom: 0.75rem;"> 1552 Add these TXT records to your DNS provider. You must control these domains to complete lexicon resolution. 1553 </div> 1554 ${cards} 1555 <div class="button-row"> 1556 <button class="btn btn-secondary" onclick="verifyDnsAndRender()">Verify DNS</button> 1557 <button class="btn btn-primary" onclick="continueToDone()"> 1558 ${allVerified ? 'Continue' : 'Skip for Now'} 1559 </button> 1560 </div> 1561 `; 1562 } 1563 1564 async function verifyDnsAndRender() { 1565 await verifyDnsRecords(); 1566 renderAllSteps(); 1567 } 1568 1569 function skipDnsAndContinue() { 1570 // Mark DNS step as complete (non-blocking) and continue 1571 state.stepCompleted[4] = true; 1572 state.currentStep = 5; 1573 renderAllSteps(); 1574 } 1575 1576 function continueToDone() { 1577 // Mark DNS step as complete and move to final step 1578 state.stepCompleted[4] = true; 1579 state.currentStep = 5; 1580 renderAllSteps(); 1581 } 1582 1583 function renderPublishedLexiconsContent() { 1584 if (state.publishedLexicons.length === 0) { 1585 return ` 1586 <p style="color: var(--text-secondary);"> 1587 You haven't published any lexicons yet. 1588 </p> 1589 `; 1590 } 1591 1592 const lexiconCards = state.publishedLexicons.map(lex => ` 1593 <div class="published-card"> 1594 <div class="published-header"> 1595 <span class="published-nsid">${esc(lex.rkey)}</span> 1596 <button class="btn btn-danger btn-small" onclick="deleteLexiconAndRender('${escapeAttr(lex.rkey)}')"> 1597 Delete 1598 </button> 1599 </div> 1600 </div> 1601 `).join(''); 1602 1603 return ` 1604 <p style="color: var(--text-secondary); margin-bottom: 0.75rem;"> 1605 You have ${state.publishedLexicons.length} published lexicon${state.publishedLexicons.length !== 1 ? 's' : ''}. 1606 </p> 1607 ${lexiconCards} 1608 <div class="button-row" style="margin-top: 1rem;"> 1609 <button class="btn btn-primary" onclick="goToDefineStep()">Add or Update Lexicons</button> 1610 </div> 1611 `; 1612 } 1613 1614 function goToDefineStep() { 1615 // Reset steps 2-4 but keep step 1 complete (still signed in) 1616 resetStepsFrom(2); 1617 state.currentStep = 2; 1618 renderAllSteps(); 1619 } 1620 1621 async function deleteLexiconAndRender(rkey) { 1622 if (!confirm(`Delete lexicon ${rkey}?`)) return; 1623 1624 try { 1625 await state.client.mutate(` 1626 mutation DeleteSchema($rkey: String!) { 1627 deleteComAtprotoLexiconSchema(rkey: $rkey) { 1628 uri 1629 } 1630 } 1631 `, { rkey }); 1632 1633 state.publishedLexicons = state.publishedLexicons.filter(l => l.rkey !== rkey); 1634 1635 // Update DNS instructions if needed 1636 if (state.publishedLexicons.length > 0) { 1637 generateDnsInstructions(state.publishedLexicons.map(l => l.rkey)); 1638 } else { 1639 state.dnsInstructions = []; 1640 } 1641 1642 renderAllSteps(); 1643 } catch (err) { 1644 alert(`Failed to delete: ${err.message}`); 1645 } 1646 } 1647 1648 async function fetchPublishedLexicons() { 1649 if (!state.viewer || !state.client) return; 1650 1651 try { 1652 const result = await state.client.query(` 1653 query { 1654 viewer { 1655 comAtprotoLexiconSchemaByDid(first: 100) { 1656 edges { 1657 node { 1658 uri 1659 defs 1660 lexicon 1661 indexedAt 1662 } 1663 } 1664 } 1665 } 1666 } 1667 `); 1668 1669 state.publishedLexicons = (result?.viewer?.comAtprotoLexiconSchemaByDid?.edges || []) 1670 .map(e => { 1671 const uri = e.node.uri; 1672 const rkey = uri.split('/').pop(); 1673 return { 1674 uri, 1675 rkey, 1676 defs: e.node.defs, 1677 lexicon: e.node.lexicon, 1678 indexedAt: e.node.indexedAt, 1679 }; 1680 }); 1681 1682 // Generate DNS instructions from published lexicons 1683 if (state.publishedLexicons.length > 0) { 1684 generateDnsInstructions(state.publishedLexicons.map(l => l.rkey)); 1685 } 1686 } catch (err) { 1687 console.error("Failed to fetch published lexicons:", err); 1688 } 1689 } 1690 1691 function getDomainAuthority(nsid) { 1692 // "buzz.bookhive.defs" → "bookhive.buzz" 1693 // "buzz.bookhive.actor.profile" → "actor.bookhive.buzz" 1694 const parts = nsid.split("."); 1695 parts.pop(); // remove name (last segment) 1696 return parts.reverse().join("."); 1697 } 1698 1699 function getUserDomain(handle) { 1700 // "bigmoves.bookhive.buzz" → "bookhive.buzz" 1701 // "chad.bsky.social" → "bsky.social" 1702 const parts = handle.split("."); 1703 if (parts.length >= 2) { 1704 return parts.slice(-2).join("."); 1705 } 1706 return handle; 1707 } 1708 1709 function getRootDomain(domain) { 1710 // "richtext.bsky.app" → "bsky.app" 1711 // "actor.bsky.app" → "bsky.app" 1712 const parts = domain.split("."); 1713 if (parts.length >= 2) { 1714 return parts.slice(-2).join("."); 1715 } 1716 return domain; 1717 } 1718 1719 function canPublishLexicon(nsid) { 1720 if (!state.viewer) return false; 1721 const userDomain = getUserDomain(state.viewer.handle); 1722 const lexiconDomain = getDomainAuthority(nsid); 1723 // Check if lexicon domain ends with user's domain 1724 return lexiconDomain === userDomain || lexiconDomain.endsWith("." + userDomain); 1725 } 1726 1727 function getPublishableLexicons() { 1728 const publishable = []; 1729 const excluded = []; 1730 1731 for (const lex of state.lexicons) { 1732 const trimmed = lex.value.trim(); 1733 if (!trimmed) continue; 1734 try { 1735 const obj = JSON.parse(trimmed); 1736 if (obj.id) { 1737 if (canPublishLexicon(obj.id)) { 1738 publishable.push({ nsid: obj.id, schema: obj }); 1739 } else { 1740 excluded.push({ nsid: obj.id, domain: getDomainAuthority(obj.id) }); 1741 } 1742 } 1743 } catch (e) {} 1744 } 1745 1746 return { publishable, excluded }; 1747 } 1748 1749 function extractExternalRefs(lexicons, userDomain) { 1750 const refs = new Set(); 1751 1752 function walk(obj) { 1753 if (!obj || typeof obj !== 'object') return; 1754 if (obj.ref && typeof obj.ref === 'string') { 1755 const nsid = obj.ref.split('#')[0]; 1756 if (!nsid) return; // Skip local refs like #main 1757 const domain = getDomainAuthority(nsid); 1758 if (domain !== userDomain && !domain.endsWith('.' + userDomain)) { 1759 refs.add(nsid); 1760 } 1761 } 1762 for (const val of Object.values(obj)) walk(val); 1763 } 1764 1765 for (const lex of lexicons) walk(lex); 1766 return [...refs]; 1767 } 1768 1769 async function checkExternalDependencies(externalNsids) { 1770 if (externalNsids.length === 0) return { found: [], missing: [] }; 1771 1772 try { 1773 const result = await state.client.query(` 1774 query CheckDeps($nsids: [String!]!) { 1775 comAtprotoLexiconSchema( 1776 first: 100, 1777 where: { id: { in: $nsids } } 1778 ) { 1779 edges { 1780 node { id } 1781 } 1782 } 1783 } 1784 `, { nsids: externalNsids }); 1785 1786 const foundIds = (result?.comAtprotoLexiconSchema?.edges || []) 1787 .map(e => e.node.id); 1788 1789 return { 1790 found: externalNsids.filter(n => foundIds.includes(n)), 1791 missing: externalNsids.filter(n => !foundIds.includes(n)), 1792 }; 1793 } catch (err) { 1794 console.error("External dependency check failed:", err); 1795 return { found: [], missing: [] }; 1796 } 1797 } 1798 1799 function generateDnsInstructions(nsids) { 1800 if (!state.viewer) return; 1801 1802 const byDomain = {}; 1803 1804 for (const nsid of nsids) { 1805 const domain = getDomainAuthority(nsid); 1806 if (!byDomain[domain]) { 1807 byDomain[domain] = []; 1808 } 1809 byDomain[domain].push(nsid); 1810 } 1811 1812 state.dnsInstructions = Object.entries(byDomain).map(([domain, lexicons]) => ({ 1813 domain, 1814 recordName: `_lexicon.${domain}`, 1815 recordValue: `did=${state.viewer.did}`, 1816 lexicons, 1817 verified: null, // null = not checked, true = verified, false = not found 1818 })); 1819 } 1820 1821 async function resolveDNS(name, type = 'TXT') { 1822 const url = new URL('https://cloudflare-dns.com/dns-query'); 1823 url.searchParams.set('name', name); 1824 url.searchParams.set('type', type); 1825 1826 const response = await fetch(url, { 1827 headers: { 'Accept': 'application/dns-json' } 1828 }); 1829 1830 return response.json(); 1831 } 1832 1833 async function verifyDnsRecords() { 1834 if (!state.viewer || state.dnsInstructions.length === 0) return; 1835 1836 const expectedValue = `did=${state.viewer.did}`; 1837 1838 for (const instr of state.dnsInstructions) { 1839 try { 1840 const result = await resolveDNS(instr.recordName, 'TXT'); 1841 const answers = result.Answer || []; 1842 // TXT records come quoted, check if any match 1843 const found = answers.some(a => { 1844 const txt = (a.data || '').replace(/^"|"$/g, ''); 1845 return txt === expectedValue; 1846 }); 1847 instr.verified = found; 1848 } catch (err) { 1849 console.error(`DNS lookup failed for ${instr.recordName}:`, err); 1850 instr.verified = false; 1851 } 1852 } 1853 } 1854 1855 function copyToClipboard(text) { 1856 navigator.clipboard.writeText(text).catch(err => { 1857 console.error('Copy failed:', err); 1858 }); 1859 } 1860 1861 // ============================================================================= 1862 // STATE UPDATES 1863 // ============================================================================= 1864 1865 function addLexicon() { 1866 state.lexicons.push({ id: state.nextLexiconId++, value: "", collapsed: false }); 1867 renderAllSteps(); 1868 } 1869 1870 function toggleLexicon(id) { 1871 const lex = state.lexicons.find((l) => l.id === id); 1872 if (lex) { 1873 lex.collapsed = !lex.collapsed; 1874 renderAllSteps(); 1875 } 1876 } 1877 1878 function toggleAllLexicons(collapse) { 1879 state.lexicons.forEach((lex) => { 1880 lex.collapsed = collapse; 1881 }); 1882 renderAllSteps(); 1883 } 1884 1885 function removeLexicon(id) { 1886 if (state.lexicons.length <= 1) return; 1887 state.lexicons = state.lexicons.filter((l) => l.id !== id); 1888 renderAllSteps(); 1889 } 1890 1891 // ============================================================================= 1892 // VALIDATION 1893 // ============================================================================= 1894 1895 function parseLexicons() { 1896 const parsed = []; 1897 for (let i = 0; i < state.lexicons.length; i++) { 1898 const lex = state.lexicons[i]; 1899 const trimmed = lex.value.trim(); 1900 1901 if (!trimmed) { 1902 return { 1903 error: `Lexicon ${i + 1}: Empty - please enter a lexicon schema`, 1904 }; 1905 } 1906 1907 // Validate JSON syntax first 1908 try { 1909 const obj = JSON.parse(trimmed); 1910 if (Array.isArray(obj)) { 1911 return { 1912 error: `Lexicon ${i + 1}: Expected a single lexicon object, not an array. Use "+ Add Lexicon" for multiple.`, 1913 }; 1914 } 1915 } catch (e) { 1916 return { error: `Lexicon ${i + 1}: Invalid JSON - ${e.message}` }; 1917 } 1918 1919 // Parse to Gleam Json type 1920 const parseResult = honk.parse_json_string(trimmed); 1921 const unwrapped = unwrapHonkResult(parseResult); 1922 if (!unwrapped.ok) { 1923 return { error: `Lexicon ${i + 1}: ${formatHonkError(unwrapped.value)}` }; 1924 } 1925 parsed.push(unwrapped.value); 1926 } 1927 return { lexicons: parsed }; 1928 } 1929 1930 async function checkConflicts() { 1931 state.conflicts = []; 1932 1933 // Only check conflicts for publishable lexicons (ones the user can actually publish) 1934 const { publishable } = getPublishableLexicons(); 1935 const nsids = publishable.map(p => p.nsid); 1936 1937 if (nsids.length === 0) return; 1938 1939 try { 1940 // Batch query for all NSIDs at once 1941 const result = await state.client.query(` 1942 query CheckConflicts($nsids: [String!]!) { 1943 comAtprotoLexiconSchema( 1944 where: { id: { in: $nsids } } 1945 first: 100 1946 ) { 1947 edges { 1948 node { 1949 id 1950 uri 1951 did 1952 actorHandle 1953 } 1954 } 1955 } 1956 } 1957 `, { nsids }); 1958 1959 const matches = result?.comAtprotoLexiconSchema?.edges || []; 1960 for (const match of matches) { 1961 const nsid = match.node.id; 1962 const existingDid = match.node.did; 1963 const existingHandle = match.node.actorHandle || existingDid; 1964 const isSameOwner = state.viewer && existingDid === state.viewer.did; 1965 1966 state.conflicts.push({ 1967 nsid, 1968 existingOwner: existingHandle, 1969 existingDid, 1970 isSameOwner, 1971 }); 1972 } 1973 } catch (err) { 1974 console.error("Conflict check failed:", err); 1975 } 1976 } 1977 1978 // ============================================================================= 1979 // ZIP FILE HANDLING 1980 // ============================================================================= 1981 1982 function handleDragOver(event) { 1983 event.preventDefault(); 1984 event.stopPropagation(); 1985 event.currentTarget.classList.add("drag-over"); 1986 } 1987 1988 function handleDragLeave(event) { 1989 event.preventDefault(); 1990 event.stopPropagation(); 1991 event.currentTarget.classList.remove("drag-over"); 1992 } 1993 1994 function handleDrop(event) { 1995 event.preventDefault(); 1996 event.stopPropagation(); 1997 event.currentTarget.classList.remove("drag-over"); 1998 1999 const files = event.dataTransfer.files; 2000 if (files.length > 0) { 2001 processZipFile(files[0]); 2002 } 2003 } 2004 2005 function triggerFileInput() { 2006 document.getElementById("file-input").click(); 2007 } 2008 2009 function handleFileSelect(event) { 2010 const files = event.target.files; 2011 if (files.length > 0) { 2012 processZipFile(files[0]); 2013 } 2014 // Reset input so the same file can be selected again 2015 event.target.value = ""; 2016 } 2017 2018 async function processZipFile(file) { 2019 if (!file.name.toLowerCase().endsWith(".zip")) { 2020 state.result = { 2021 success: false, 2022 message: `Invalid file type: ${file.name}. Please select a .zip file.`, 2023 }; 2024 renderAllSteps(); 2025 return; 2026 } 2027 2028 const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB 2029 if (file.size > MAX_FILE_SIZE) { 2030 state.result = { 2031 success: false, 2032 message: `File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum size is 10MB.`, 2033 }; 2034 renderAllSteps(); 2035 return; 2036 } 2037 2038 if (typeof JSZip === "undefined") { 2039 state.result = { 2040 success: false, 2041 message: "JSZip library failed to load. Check your internet connection.", 2042 }; 2043 renderAllSteps(); 2044 return; 2045 } 2046 2047 // Show loading state 2048 const dropZone = document.getElementById("drop-zone"); 2049 if (dropZone) { 2050 dropZone.innerHTML = ` 2051 <div class="drop-zone-icon"><span class="loading-spinner"></span></div> 2052 <div class="drop-zone-text">Processing ${esc(file.name)}...</div> 2053 `; 2054 } 2055 2056 try { 2057 const zip = await JSZip.loadAsync(file); 2058 const jsonFiles = []; 2059 2060 // Find all .json files in the zip (including nested folders) 2061 // Skip macOS metadata files (__MACOSX) and hidden files (._*) 2062 zip.forEach((relativePath, zipEntry) => { 2063 if ( 2064 !zipEntry.dir && 2065 relativePath.toLowerCase().endsWith(".json") && 2066 !relativePath.startsWith("__MACOSX/") && 2067 !relativePath.split("/").some((part) => part.startsWith("._")) 2068 ) { 2069 jsonFiles.push({ path: relativePath, entry: zipEntry }); 2070 } 2071 }); 2072 2073 if (jsonFiles.length === 0) { 2074 state.result = { 2075 success: false, 2076 message: `No .json files found in ${file.name}`, 2077 }; 2078 renderAllSteps(); 2079 return; 2080 } 2081 2082 // Extract and parse all JSON files 2083 const lexicons = []; 2084 const errors = []; 2085 2086 for (const { path, entry } of jsonFiles) { 2087 try { 2088 const content = await entry.async("string"); 2089 // Validate it's valid JSON 2090 JSON.parse(content); 2091 lexicons.push({ path, content }); 2092 } catch (e) { 2093 errors.push(`${path}: ${e.message}`); 2094 } 2095 } 2096 2097 if (lexicons.length === 0) { 2098 state.result = { 2099 success: false, 2100 message: `All JSON files had parse errors:\n${errors.join("\n")}`, 2101 }; 2102 renderAllSteps(); 2103 return; 2104 } 2105 2106 // Replace current lexicons with extracted ones (collapsed by default) 2107 state.lexicons = lexicons.map((lex, idx) => ({ 2108 id: state.nextLexiconId + idx, 2109 value: lex.content, 2110 collapsed: true, 2111 })); 2112 state.nextLexiconId += lexicons.length; 2113 2114 const successMsg = `Loaded ${lexicons.length} lexicon${lexicons.length === 1 ? "" : "s"} from ${file.name}`; 2115 const errorMsg = 2116 errors.length > 0 2117 ? `\n\nSkipped ${errors.length} file${errors.length === 1 ? "" : "s"}:\n${errors.join("\n")}` 2118 : ""; 2119 2120 state.result = { 2121 success: true, 2122 message: successMsg + errorMsg, 2123 }; 2124 2125 renderAllSteps(); 2126 } catch (e) { 2127 state.result = { 2128 success: false, 2129 message: `Failed to read zip file: ${e.message}`, 2130 }; 2131 renderAllSteps(); 2132 } 2133 } 2134 2135 // ============================================================================= 2136 // INIT 2137 // ============================================================================= 2138 2139 async function init() { 2140 if (typeof honk === "undefined") { 2141 document.getElementById("steps-container").innerHTML = ` 2142 <div class="result result-error" style="margin: 1rem;">✗ Failed to load honk library from CDN. Check your internet connection.</div> 2143 `; 2144 return; 2145 } 2146 2147 // Handle OAuth callback 2148 if (window.location.search.includes("code=")) { 2149 try { 2150 await initClient(); 2151 await state.client.handleRedirectCallback(); 2152 window.history.replaceState({}, "", window.location.pathname); 2153 } catch (err) { 2154 console.error("OAuth callback error:", err); 2155 state.result = { success: false, message: "Authentication failed: " + err.message }; 2156 } 2157 } else { 2158 try { 2159 await initClient(); 2160 } catch (err) { 2161 console.error("Failed to initialize client:", err); 2162 } 2163 } 2164 2165 // Check if authenticated and fetch viewer + published lexicons 2166 if (state.client) { 2167 try { 2168 const isLoggedIn = await state.client.isAuthenticated(); 2169 if (isLoggedIn) { 2170 const result = await state.client.query(` 2171 query { 2172 viewer { 2173 did 2174 handle 2175 comAtprotoLexiconSchemaByDid(first: 100) { 2176 edges { 2177 node { 2178 uri 2179 defs 2180 lexicon 2181 indexedAt 2182 } 2183 } 2184 } 2185 } 2186 } 2187 `); 2188 if (result?.viewer) { 2189 state.viewer = { did: result.viewer.did, handle: result.viewer.handle }; 2190 state.publishedLexicons = (result.viewer.comAtprotoLexiconSchemaByDid?.edges || []) 2191 .map(e => { 2192 const uri = e.node.uri; 2193 const rkey = uri.split('/').pop(); 2194 return { 2195 uri, 2196 rkey, 2197 defs: e.node.defs, 2198 lexicon: e.node.lexicon, 2199 indexedAt: e.node.indexedAt, 2200 }; 2201 }); 2202 2203 // Generate DNS instructions from published lexicons 2204 if (state.publishedLexicons.length > 0) { 2205 generateDnsInstructions(state.publishedLexicons.map(l => l.rkey)); 2206 } 2207 2208 // Complete step 1 since we're signed in 2209 state.stepCompleted[1] = true; 2210 2211 // If returning user has published lexicons, skip to step 5 2212 if (state.publishedLexicons.length > 0) { 2213 state.stepCompleted[2] = true; 2214 state.stepCompleted[3] = true; 2215 state.stepCompleted[4] = true; 2216 state.currentStep = 5; 2217 } else { 2218 state.currentStep = 2; 2219 } 2220 } 2221 } 2222 } catch (err) { 2223 console.error("Auth check error:", err); 2224 } 2225 } 2226 2227 renderAllSteps(); 2228 } 2229 2230 window.addEventListener("DOMContentLoaded", init); 2231 </script> 2232 </body> 2233</html>