Auto-indexing service and GraphQL API for AT Protocol Records
at main 826 lines 22 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://unpkg.com; style-src 'self' 'unsafe-inline'; connect-src http://localhost:8080 http://127.0.0.1:8080; img-src 'self' https: data:;" 9 /> 10 <title>Statusphere</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 font: inherit; 28 } 29 30 /* CSS Variables */ 31 :root { 32 --primary-500: #0078ff; 33 --primary-400: #339dff; 34 --primary-600: #0060cc; 35 --gray-100: #f5f5f5; 36 --gray-200: #e5e5e5; 37 --gray-500: #737373; 38 --gray-700: #404040; 39 --gray-900: #171717; 40 --border-color: #e5e5e5; 41 --error-bg: #fef2f2; 42 --error-border: #fecaca; 43 --error-text: #dc2626; 44 } 45 46 /* Layout */ 47 body { 48 font-family: 49 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 50 background: var(--gray-100); 51 color: var(--gray-900); 52 min-height: 100vh; 53 padding: 2rem 1rem; 54 } 55 56 #app { 57 max-width: 600px; 58 margin: 0 auto; 59 } 60 61 /* Header */ 62 header { 63 text-align: center; 64 margin-bottom: 2rem; 65 } 66 67 header h1 { 68 font-size: 2.5rem; 69 color: var(--primary-500); 70 margin-bottom: 0.25rem; 71 } 72 73 .tagline { 74 color: var(--gray-500); 75 font-size: 1rem; 76 } 77 78 /* Cards */ 79 .card { 80 background: white; 81 border-radius: 0.5rem; 82 padding: 1.5rem; 83 margin-bottom: 1rem; 84 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 85 } 86 87 /* Auth Section */ 88 .login-form { 89 display: flex; 90 flex-direction: column; 91 gap: 1rem; 92 } 93 94 .form-group { 95 display: flex; 96 flex-direction: column; 97 gap: 0.25rem; 98 } 99 100 .form-group label { 101 font-size: 0.875rem; 102 font-weight: 500; 103 color: var(--gray-700); 104 } 105 106 .form-group input { 107 padding: 0.75rem; 108 border: 1px solid var(--border-color); 109 border-radius: 0.375rem; 110 font-size: 1rem; 111 } 112 113 .form-group input:focus { 114 outline: none; 115 border-color: var(--primary-500); 116 box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1); 117 } 118 119 .btn { 120 padding: 0.75rem 1.5rem; 121 border: none; 122 border-radius: 0.375rem; 123 font-size: 1rem; 124 font-weight: 500; 125 cursor: pointer; 126 transition: background-color 0.15s; 127 } 128 129 .btn-primary { 130 background: var(--primary-500); 131 color: white; 132 } 133 134 .btn-primary:hover { 135 background: var(--primary-600); 136 } 137 138 .btn-primary:disabled { 139 background: var(--gray-200); 140 color: var(--gray-500); 141 cursor: not-allowed; 142 } 143 144 .btn-secondary { 145 background: var(--gray-200); 146 color: var(--gray-700); 147 } 148 149 .btn-secondary:hover { 150 background: var(--border-color); 151 } 152 153 /* User Card */ 154 .user-card { 155 display: flex; 156 align-items: center; 157 justify-content: space-between; 158 } 159 160 .user-info { 161 display: flex; 162 align-items: center; 163 gap: 0.75rem; 164 } 165 166 .user-avatar { 167 width: 48px; 168 height: 48px; 169 border-radius: 50%; 170 background: var(--gray-200); 171 display: flex; 172 align-items: center; 173 justify-content: center; 174 font-size: 1.5rem; 175 } 176 177 .user-avatar img { 178 width: 100%; 179 height: 100%; 180 border-radius: 50%; 181 object-fit: cover; 182 } 183 184 .user-name { 185 font-weight: 600; 186 } 187 188 .user-handle { 189 font-size: 0.875rem; 190 color: var(--gray-500); 191 } 192 193 /* Emoji Picker */ 194 .emoji-grid { 195 display: grid; 196 grid-template-columns: repeat(9, 1fr); 197 gap: 0.5rem; 198 } 199 200 .emoji-btn { 201 width: 100%; 202 aspect-ratio: 1; 203 font-size: 1.5rem; 204 border: 2px solid var(--border-color); 205 border-radius: 50%; 206 background: white; 207 cursor: pointer; 208 transition: all 0.15s; 209 display: flex; 210 align-items: center; 211 justify-content: center; 212 } 213 214 .emoji-btn:hover { 215 background: rgba(0, 120, 255, 0.1); 216 border-color: var(--primary-400); 217 } 218 219 .emoji-btn.selected { 220 border-color: var(--primary-500); 221 box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.2); 222 } 223 224 .emoji-btn:disabled { 225 opacity: 0.5; 226 cursor: not-allowed; 227 } 228 229 .emoji-btn:disabled:hover { 230 background: white; 231 border-color: var(--border-color); 232 } 233 234 /* Status Feed */ 235 .feed-title { 236 font-size: 1.125rem; 237 font-weight: 600; 238 margin-bottom: 1rem; 239 color: var(--gray-700); 240 } 241 242 .status-list { 243 list-style: none; 244 padding: 0; 245 } 246 247 .status-item { 248 position: relative; 249 padding-left: 2rem; 250 padding-bottom: 1.5rem; 251 } 252 253 .status-item::before { 254 content: ""; 255 position: absolute; 256 left: 0.75rem; 257 top: 1.5rem; 258 bottom: 0; 259 width: 2px; 260 background: var(--border-color); 261 } 262 263 .status-item:last-child::before { 264 display: none; 265 } 266 267 .status-item:last-child { 268 padding-bottom: 0; 269 } 270 271 .status-emoji { 272 position: absolute; 273 left: 0; 274 top: 0; 275 font-size: 1.5rem; 276 } 277 278 .status-content { 279 padding-top: 0.25rem; 280 } 281 282 .status-author { 283 color: var(--primary-500); 284 text-decoration: none; 285 font-weight: 500; 286 } 287 288 .status-author:hover { 289 text-decoration: underline; 290 } 291 292 .status-text { 293 color: var(--gray-700); 294 } 295 296 .status-date { 297 font-size: 0.875rem; 298 color: var(--gray-500); 299 } 300 301 /* Error Banner */ 302 #error-banner { 303 position: fixed; 304 top: 1rem; 305 left: 50%; 306 transform: translateX(-50%); 307 background: var(--error-bg); 308 border: 1px solid var(--error-border); 309 color: var(--error-text); 310 padding: 0.75rem 1rem; 311 border-radius: 0.375rem; 312 display: flex; 313 align-items: center; 314 gap: 0.75rem; 315 max-width: 90%; 316 z-index: 100; 317 } 318 319 #error-banner.hidden { 320 display: none; 321 } 322 323 #error-banner button { 324 background: none; 325 border: none; 326 color: var(--error-text); 327 cursor: pointer; 328 font-size: 1.25rem; 329 line-height: 1; 330 } 331 332 /* Loading State */ 333 .loading { 334 text-align: center; 335 color: var(--gray-500); 336 padding: 2rem; 337 } 338 339 /* Responsive */ 340 @media (max-width: 480px) { 341 .emoji-grid { 342 grid-template-columns: repeat(6, 1fr); 343 } 344 345 .emoji-btn { 346 font-size: 1.25rem; 347 } 348 } 349 350 /* Hidden utility */ 351 .hidden { 352 display: none !important; 353 } 354 </style> 355 </head> 356 <body> 357 <div id="app"> 358 <header> 359 <h1>Statusphere</h1> 360 <p class="tagline">Set your status on the Atmosphere</p> 361 </header> 362 <main> 363 <div id="auth-section"></div> 364 <div id="emoji-picker"></div> 365 <div id="status-feed"></div> 366 </main> 367 <div id="error-banner" class="hidden"></div> 368 </div> 369 370 <!-- Quickslice Client SDK --> 371 <script src="https://unpkg.com/quickslice-client-js/dist/quickslice-client.min.js"></script> 372 373 <script> 374 // ============================================================================= 375 // CONFIGURATION 376 // ============================================================================= 377 378 // Use the same hostname as the current page to avoid DPoP htu mismatch 379 const SERVER_URL = `http://${window.location.hostname}:8080`; 380 const CLIENT_ID = ""; // Set your OAuth client ID here 381 382 const EMOJIS = [ 383 "👍", 384 "👎", 385 "💙", 386 "😧", 387 "😤", 388 "🙃", 389 "😉", 390 "😎", 391 "🤩", 392 "🥳", 393 "😭", 394 "😱", 395 "🥺", 396 "😡", 397 "💀", 398 "🤖", 399 "👻", 400 "👽", 401 "🎃", 402 "🤡", 403 "💩", 404 "🔥", 405 "⭐", 406 "🌈", 407 "🍕", 408 "🎉", 409 "💯", 410 ]; 411 412 // Client instance 413 let client; 414 415 // ============================================================================= 416 // INITIALIZATION 417 // ============================================================================= 418 419 async function main() { 420 // Check if this is an OAuth callback 421 if (window.location.search.includes("code=")) { 422 if (!CLIENT_ID) { 423 showError( 424 "OAuth callback received but CLIENT_ID is not configured.", 425 ); 426 renderLoginForm(); 427 return; 428 } 429 430 try { 431 client = await QuicksliceClient.createQuicksliceClient({ 432 server: SERVER_URL, 433 clientId: CLIENT_ID, 434 }); 435 await client.handleRedirectCallback(); 436 console.log("OAuth callback handled successfully"); 437 } catch (error) { 438 console.error("OAuth callback error:", error); 439 showError(`Authentication failed: ${error.message}`); 440 renderLoginForm(); 441 renderEmojiPicker(null, false); 442 await loadAndRenderStatuses(); 443 return; 444 } 445 } else if (CLIENT_ID) { 446 // Initialize client with configured ID 447 try { 448 client = await QuicksliceClient.createQuicksliceClient({ 449 server: SERVER_URL, 450 clientId: CLIENT_ID, 451 }); 452 } catch (error) { 453 console.error("Failed to initialize client:", error); 454 } 455 } 456 457 // Render based on auth state 458 await renderApp(); 459 } 460 461 async function renderApp() { 462 const isLoggedIn = client && (await client.isAuthenticated()); 463 464 if (isLoggedIn) { 465 try { 466 const viewer = await fetchViewer(); 467 renderUserCard(viewer); 468 } catch (error) { 469 console.error("Failed to fetch viewer:", error); 470 renderUserCard(null); 471 } 472 } else { 473 renderLoginForm(); 474 } 475 476 // Render emoji picker (enabled only if logged in) 477 renderEmojiPicker(null, isLoggedIn); 478 479 // Load statuses 480 await loadAndRenderStatuses(); 481 } 482 483 // ============================================================================= 484 // DATA FETCHING 485 // ============================================================================= 486 487 async function fetchStatuses() { 488 const query = ` 489 query GetStatuses { 490 xyzStatusphereStatus( 491 first: 20 492 sortBy: [{ field: "createdAt", direction: DESC }] 493 ) { 494 edges { 495 node { 496 uri 497 did 498 status 499 createdAt 500 appBskyActorProfileByDid { 501 actorHandle 502 displayName 503 } 504 } 505 } 506 } 507 } 508 `; 509 510 // Use client if available, otherwise create a temporary one for public query 511 if (client) { 512 const data = await client.publicQuery(query); 513 return data.xyzStatusphereStatus?.edges?.map((e) => e.node) || []; 514 } else { 515 // For unauthenticated users, make a direct fetch 516 const response = await fetch(`${SERVER_URL}/graphql`, { 517 method: "POST", 518 headers: { "Content-Type": "application/json" }, 519 body: JSON.stringify({ query }), 520 }); 521 const result = await response.json(); 522 return ( 523 result.data?.xyzStatusphereStatus?.edges?.map((e) => e.node) || [] 524 ); 525 } 526 } 527 528 async function fetchViewer() { 529 const query = ` 530 query { 531 viewer { 532 did 533 handle 534 appBskyActorProfileByDid { 535 displayName 536 avatar { url } 537 } 538 } 539 } 540 `; 541 542 const data = await client.query(query); 543 return data?.viewer; 544 } 545 546 async function postStatus(emoji) { 547 const mutation = ` 548 mutation CreateStatus($status: String!, $createdAt: DateTime!) { 549 createXyzStatusphereStatus( 550 input: { status: $status, createdAt: $createdAt } 551 ) { 552 uri 553 status 554 createdAt 555 } 556 } 557 `; 558 559 const variables = { 560 status: emoji, 561 createdAt: new Date().toISOString(), 562 }; 563 564 return await client.mutate(mutation, variables); 565 } 566 567 async function loadAndRenderStatuses() { 568 renderLoading("status-feed"); 569 try { 570 const statuses = await fetchStatuses(); 571 renderStatusFeed(statuses); 572 } catch (error) { 573 console.error("Failed to fetch statuses:", error); 574 document.getElementById("status-feed").innerHTML = ` 575 <div class="card"> 576 <p class="loading" style="color: var(--error-text);"> 577 Failed to load statuses. Is the quickslice server running at ${SERVER_URL}? 578 </p> 579 </div> 580 `; 581 } 582 } 583 584 // ============================================================================= 585 // EVENT HANDLERS 586 // ============================================================================= 587 588 async function handleLogin(event) { 589 event.preventDefault(); 590 591 const handle = document.getElementById("handle").value.trim(); 592 593 if (!handle) { 594 showError("Please enter your Bluesky handle"); 595 return; 596 } 597 598 try { 599 client = await QuicksliceClient.createQuicksliceClient({ 600 server: SERVER_URL, 601 clientId: CLIENT_ID, 602 }); 603 604 await client.loginWithRedirect({ handle }); 605 } catch (error) { 606 showError(`Login failed: ${error.message}`); 607 } 608 } 609 610 async function selectStatus(emoji) { 611 if (!client || !(await client.isAuthenticated())) { 612 showError("Please login to set your status"); 613 return; 614 } 615 616 try { 617 // Disable buttons while posting 618 document 619 .querySelectorAll(".emoji-btn") 620 .forEach((btn) => (btn.disabled = true)); 621 622 await postStatus(emoji); 623 624 // Refresh the page to show new status 625 window.location.reload(); 626 } catch (error) { 627 showError(`Failed to post status: ${error.message}`); 628 // Re-enable buttons 629 document 630 .querySelectorAll(".emoji-btn") 631 .forEach((btn) => (btn.disabled = false)); 632 } 633 } 634 635 function logout() { 636 if (client) { 637 client.logout(); 638 } else { 639 window.location.reload(); 640 } 641 } 642 643 // ============================================================================= 644 // UI RENDERING 645 // ============================================================================= 646 647 function showError(message) { 648 const banner = document.getElementById("error-banner"); 649 banner.innerHTML = ` 650 <span>${escapeHtml(message)}</span> 651 <button onclick="hideError()">&times;</button> 652 `; 653 banner.classList.remove("hidden"); 654 } 655 656 function hideError() { 657 document.getElementById("error-banner").classList.add("hidden"); 658 } 659 660 function escapeHtml(text) { 661 const div = document.createElement("div"); 662 div.textContent = text; 663 return div.innerHTML; 664 } 665 666 function formatDate(dateString) { 667 const date = new Date(dateString); 668 const now = new Date(); 669 const isToday = date.toDateString() === now.toDateString(); 670 671 if (isToday) { 672 return "today"; 673 } 674 675 return date.toLocaleDateString("en-US", { 676 month: "short", 677 day: "numeric", 678 year: 679 date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 680 }); 681 } 682 683 function renderLoginForm() { 684 const container = document.getElementById("auth-section"); 685 686 // Show configuration message if CLIENT_ID is not set 687 if (!CLIENT_ID) { 688 container.innerHTML = ` 689 <div class="card"> 690 <p style="color: var(--error-text); text-align: center; margin-bottom: 1rem;"> 691 <strong>Configuration Required</strong> 692 </p> 693 <p style="color: var(--gray-700); text-align: center;"> 694 Please set the <code style="background: var(--gray-100); padding: 0.125rem 0.375rem; border-radius: 0.25rem;">CLIENT_ID</code> constant in this file to your OAuth client ID. 695 </p> 696 </div> 697 `; 698 return; 699 } 700 701 container.innerHTML = ` 702 <div class="card"> 703 <form class="login-form" onsubmit="handleLogin(event)"> 704 <div class="form-group"> 705 <label for="handle">Bluesky Handle</label> 706 <input 707 type="text" 708 id="handle" 709 placeholder="you.bsky.social" 710 required 711 > 712 </div> 713 <button type="submit" class="btn btn-primary">Login with Bluesky</button> 714 </form> 715 <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;"> 716 Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a> 717 </p> 718 </div> 719 `; 720 } 721 722 function renderUserCard(viewer) { 723 const container = document.getElementById("auth-section"); 724 const displayName = 725 viewer?.appBskyActorProfileByDid?.displayName || "User"; 726 const handle = viewer?.handle || "unknown"; 727 const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url; 728 729 container.innerHTML = ` 730 <div class="card user-card"> 731 <div class="user-info"> 732 <div class="user-avatar"> 733 ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` : "👤"} 734 </div> 735 <div> 736 <div class="user-name">Hi, ${escapeHtml(displayName)}!</div> 737 <div class="user-handle">@${escapeHtml(handle)}</div> 738 </div> 739 </div> 740 <button class="btn btn-secondary" onclick="logout()">Logout</button> 741 </div> 742 `; 743 } 744 745 function renderEmojiPicker(currentStatus, enabled = true) { 746 const container = document.getElementById("emoji-picker"); 747 748 container.innerHTML = ` 749 <div class="card"> 750 <div class="emoji-grid"> 751 ${EMOJIS.map( 752 (emoji) => ` 753 <button 754 class="emoji-btn ${emoji === currentStatus ? "selected" : ""}" 755 onclick="selectStatus('${emoji}')" 756 ${!enabled ? "disabled" : ""} 757 title="${enabled ? "Set status" : "Login to set status"}" 758 > 759 ${emoji} 760 </button> 761 `, 762 ).join("")} 763 </div> 764 </div> 765 `; 766 } 767 768 function renderStatusFeed(statuses) { 769 const container = document.getElementById("status-feed"); 770 771 if (statuses.length === 0) { 772 container.innerHTML = ` 773 <div class="card"> 774 <p class="loading">No statuses yet. Be the first to post!</p> 775 </div> 776 `; 777 return; 778 } 779 780 container.innerHTML = ` 781 <div class="card"> 782 <h2 class="feed-title">Recent Statuses</h2> 783 <ul class="status-list"> 784 ${statuses 785 .map((status) => { 786 const handle = 787 status.appBskyActorProfileByDid?.actorHandle || status.did; 788 const displayHandle = handle.startsWith("did:") 789 ? handle.substring(0, 20) + "..." 790 : handle; 791 const profileUrl = handle.startsWith("did:") 792 ? `https://bsky.app/profile/${status.did}` 793 : `https://bsky.app/profile/${handle}`; 794 795 return ` 796 <li class="status-item"> 797 <span class="status-emoji">${status.status}</span> 798 <div class="status-content"> 799 <span class="status-text"> 800 <a href="${profileUrl}" target="_blank" class="status-author">@${escapeHtml(displayHandle)}</a> 801 is feeling ${status.status} 802 </span> 803 <div class="status-date">${formatDate(status.createdAt)}</div> 804 </div> 805 </li> 806 `; 807 }) 808 .join("")} 809 </ul> 810 </div> 811 `; 812 } 813 814 function renderLoading(container) { 815 document.getElementById(container).innerHTML = ` 816 <div class="card"> 817 <p class="loading">Loading...</p> 818 </div> 819 `; 820 } 821 822 // Run on page load 823 main(); 824 </script> 825 </body> 826</html>