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