Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

feat(example): [wip] add following feed example with profile pages

+1440 -327
+1017
dev-docs/plans/2025-12-04-following-feed-example.md
··· 1 + # Following Feed Example Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Transform the 02-following-feed example from a Statusphere clone into a Bluesky profile posts viewer with URL-based routing. 6 + 7 + **Architecture:** Single HTML file with client-side routing using History API. Login redirects to `/profile/{handle}`, profile pages fetch posts via GraphQL query filtering by actorHandle and excluding replies. Read-only (no posting). 8 + 9 + **Tech Stack:** Vanilla HTML/CSS/JavaScript, GraphQL, OAuth PKCE 10 + 11 + --- 12 + 13 + ## Task 1: Update Branding and Remove Statusphere Elements 14 + 15 + **Files:** 16 + - Modify: `examples/02-following-feed/index.html` 17 + 18 + **Step 1: Update HTML header and title** 19 + 20 + Change the title and header from "Statusphere" to "Following Feed": 21 + 22 + ```html 23 + <title>Following Feed</title> 24 + ``` 25 + 26 + ```html 27 + <header> 28 + <h1>Following Feed</h1> 29 + <p class="tagline">View posts on the Atmosphere</p> 30 + </header> 31 + ``` 32 + 33 + **Step 2: Remove emoji picker container from HTML** 34 + 35 + Remove this line from the main section: 36 + ```html 37 + <div id="emoji-picker"></div> 38 + ``` 39 + 40 + **Step 3: Remove status feed container, replace with posts feed** 41 + 42 + Change: 43 + ```html 44 + <div id="status-feed"></div> 45 + ``` 46 + To: 47 + ```html 48 + <div id="posts-feed"></div> 49 + ``` 50 + 51 + **Step 4: Test manually** 52 + 53 + Open `examples/02-following-feed/index.html` in browser. 54 + Expected: See "Following Feed" title, no emoji picker section. 55 + 56 + **Step 5: Commit** 57 + 58 + ```bash 59 + git add examples/02-following-feed/index.html 60 + git commit -m "feat(example): update 02-following-feed branding, remove emoji picker" 61 + ``` 62 + 63 + --- 64 + 65 + ## Task 2: Remove Emoji-Related JavaScript 66 + 67 + **Files:** 68 + - Modify: `examples/02-following-feed/index.html` 69 + 70 + **Step 1: Remove EMOJIS constant** 71 + 72 + Delete these lines from the CONSTANTS section: 73 + ```javascript 74 + const EMOJIS = [ 75 + '👍', '👎', '💙', '😧', '😤', '🙃', '😉', '😎', '🤩', 76 + '🥳', '😭', '😱', '🥺', '😡', '💀', '🤖', '👻', '👽', 77 + '🎃', '🤡', '💩', '🔥', '⭐', '🌈', '🍕', '🎉', '💯' 78 + ]; 79 + ``` 80 + 81 + **Step 2: Remove postStatus function** 82 + 83 + Delete the entire `postStatus` function (lines ~645-665): 84 + ```javascript 85 + async function postStatus(emoji) { 86 + // ... entire function 87 + } 88 + ``` 89 + 90 + **Step 3: Remove renderEmojiPicker function** 91 + 92 + Delete the entire `renderEmojiPicker` function (lines ~765-784): 93 + ```javascript 94 + function renderEmojiPicker(currentStatus, enabled = true) { 95 + // ... entire function 96 + } 97 + ``` 98 + 99 + **Step 4: Remove selectStatus function** 100 + 101 + Delete the entire `selectStatus` function (lines ~857-876): 102 + ```javascript 103 + async function selectStatus(emoji) { 104 + // ... entire function 105 + } 106 + ``` 107 + 108 + **Step 5: Test manually** 109 + 110 + Open in browser, check console for errors. 111 + Expected: No JavaScript errors, page loads without emoji picker. 112 + 113 + **Step 6: Commit** 114 + 115 + ```bash 116 + git add examples/02-following-feed/index.html 117 + git commit -m "feat(example): remove emoji picker and status posting code" 118 + ``` 119 + 120 + --- 121 + 122 + ## Task 3: Remove Statusphere Status Feed Code 123 + 124 + **Files:** 125 + - Modify: `examples/02-following-feed/index.html` 126 + 127 + **Step 1: Remove fetchStatuses function** 128 + 129 + Delete the entire `fetchStatuses` function that queries `xyzStatusphereStatus`: 130 + ```javascript 131 + async function fetchStatuses() { 132 + const query = ` 133 + query GetStatuses { 134 + xyzStatusphereStatus( 135 + // ... 136 + ) { 137 + // ... 138 + } 139 + } 140 + `; 141 + // ... 142 + } 143 + ``` 144 + 145 + **Step 2: Remove renderStatusFeed function** 146 + 147 + Delete the entire `renderStatusFeed` function. 148 + 149 + **Step 3: Remove status-related CSS** 150 + 151 + Delete these CSS blocks: 152 + - `.status-list` 153 + - `.status-item` and its `::before` pseudo-elements 154 + - `.status-emoji` 155 + - `.status-content` 156 + - `.status-author` 157 + - `.status-text` 158 + - `.status-date` 159 + 160 + **Step 4: Commit** 161 + 162 + ```bash 163 + git add examples/02-following-feed/index.html 164 + git commit -m "feat(example): remove statusphere feed code and styles" 165 + ``` 166 + 167 + --- 168 + 169 + ## Task 4: Add Client-Side Routing 170 + 171 + **Files:** 172 + - Modify: `examples/02-following-feed/index.html` 173 + 174 + **Step 1: Add router utility object** 175 + 176 + Add after the storage utilities section: 177 + 178 + ```javascript 179 + // ============================================================================= 180 + // ROUTING 181 + // ============================================================================= 182 + 183 + const router = { 184 + getPath() { 185 + return window.location.pathname; 186 + }, 187 + 188 + getProfileHandle() { 189 + const match = this.getPath().match(/^\/profile\/(.+)$/); 190 + return match ? decodeURIComponent(match[1]) : null; 191 + }, 192 + 193 + navigateTo(path) { 194 + window.history.pushState({}, '', path); 195 + renderApp(); 196 + }, 197 + 198 + isProfilePage() { 199 + return this.getPath().startsWith('/profile/'); 200 + } 201 + }; 202 + 203 + // Handle browser back/forward 204 + window.addEventListener('popstate', () => renderApp()); 205 + ``` 206 + 207 + **Step 2: Commit** 208 + 209 + ```bash 210 + git add examples/02-following-feed/index.html 211 + git commit -m "feat(example): add client-side routing utilities" 212 + ``` 213 + 214 + --- 215 + 216 + ## Task 5: Add Posts Fetching Function 217 + 218 + **Files:** 219 + - Modify: `examples/02-following-feed/index.html` 220 + 221 + **Step 1: Add fetchPosts function** 222 + 223 + Add to the DATA FETCHING section: 224 + 225 + ```javascript 226 + async function fetchPosts(handle) { 227 + const query = ` 228 + query GetPosts($handle: String!) { 229 + appBskyFeedPost( 230 + sortBy: [{direction: DESC, field: createdAt}] 231 + where: { 232 + and: [ 233 + {actorHandle: {eq: $handle}}, 234 + {reply: {isNull: true}} 235 + ] 236 + } 237 + ) { 238 + edges { 239 + node { 240 + uri 241 + text 242 + createdAt 243 + appBskyActorProfileByDid { 244 + displayName 245 + actorHandle 246 + avatar { 247 + url 248 + } 249 + } 250 + embed { 251 + ... on AppBskyEmbedImages { 252 + images { 253 + image { 254 + url 255 + } 256 + } 257 + } 258 + } 259 + } 260 + } 261 + } 262 + } 263 + `; 264 + 265 + const data = await graphqlQuery(query, { handle }, true); 266 + return data.appBskyFeedPost?.edges?.map(e => e.node) || []; 267 + } 268 + ``` 269 + 270 + **Step 2: Commit** 271 + 272 + ```bash 273 + git add examples/02-following-feed/index.html 274 + git commit -m "feat(example): add fetchPosts function for profile posts" 275 + ``` 276 + 277 + --- 278 + 279 + ## Task 6: Add Profile Fetching Function 280 + 281 + **Files:** 282 + - Modify: `examples/02-following-feed/index.html` 283 + 284 + **Step 1: Add fetchProfile function** 285 + 286 + Add to the DATA FETCHING section: 287 + 288 + ```javascript 289 + async function fetchProfile(handle) { 290 + const query = ` 291 + query GetProfile($handle: String!) { 292 + appBskyActorProfile( 293 + where: {actorHandle: {eq: $handle}} 294 + first: 1 295 + ) { 296 + edges { 297 + node { 298 + did 299 + actorHandle 300 + displayName 301 + avatar { 302 + url 303 + } 304 + } 305 + } 306 + } 307 + } 308 + `; 309 + 310 + const data = await graphqlQuery(query, { handle }, true); 311 + const edges = data.appBskyActorProfile?.edges || []; 312 + return edges.length > 0 ? edges[0].node : null; 313 + } 314 + ``` 315 + 316 + **Step 2: Commit** 317 + 318 + ```bash 319 + git add examples/02-following-feed/index.html 320 + git commit -m "feat(example): add fetchProfile function" 321 + ``` 322 + 323 + --- 324 + 325 + ## Task 7: Add Post Card CSS 326 + 327 + **Files:** 328 + - Modify: `examples/02-following-feed/index.html` 329 + 330 + **Step 1: Add post card styles** 331 + 332 + Add to the CSS section: 333 + 334 + ```css 335 + /* Post Cards */ 336 + .posts-list { 337 + display: flex; 338 + flex-direction: column; 339 + gap: 1rem; 340 + } 341 + 342 + .post-card { 343 + background: white; 344 + border-radius: 0.5rem; 345 + padding: 1rem; 346 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 347 + } 348 + 349 + .post-header { 350 + display: flex; 351 + align-items: center; 352 + gap: 0.75rem; 353 + margin-bottom: 0.75rem; 354 + } 355 + 356 + .post-avatar { 357 + width: 40px; 358 + height: 40px; 359 + border-radius: 50%; 360 + background: var(--gray-200); 361 + overflow: hidden; 362 + flex-shrink: 0; 363 + } 364 + 365 + .post-avatar img { 366 + width: 100%; 367 + height: 100%; 368 + object-fit: cover; 369 + } 370 + 371 + .post-author-info { 372 + flex: 1; 373 + min-width: 0; 374 + } 375 + 376 + .post-author-name { 377 + font-weight: 600; 378 + color: var(--gray-900); 379 + white-space: nowrap; 380 + overflow: hidden; 381 + text-overflow: ellipsis; 382 + } 383 + 384 + .post-author-handle { 385 + font-size: 0.875rem; 386 + color: var(--gray-500); 387 + } 388 + 389 + .post-text { 390 + color: var(--gray-700); 391 + line-height: 1.5; 392 + white-space: pre-wrap; 393 + word-break: break-word; 394 + } 395 + 396 + .post-images { 397 + display: grid; 398 + gap: 0.5rem; 399 + margin-top: 0.75rem; 400 + } 401 + 402 + .post-images.single { 403 + grid-template-columns: 1fr; 404 + } 405 + 406 + .post-images.multiple { 407 + grid-template-columns: repeat(2, 1fr); 408 + } 409 + 410 + .post-images img { 411 + width: 100%; 412 + border-radius: 0.5rem; 413 + max-height: 300px; 414 + object-fit: cover; 415 + } 416 + 417 + .post-date { 418 + font-size: 0.75rem; 419 + color: var(--gray-500); 420 + margin-top: 0.75rem; 421 + } 422 + 423 + /* Profile Header */ 424 + .profile-header { 425 + display: flex; 426 + align-items: center; 427 + gap: 1rem; 428 + margin-bottom: 1.5rem; 429 + } 430 + 431 + .profile-avatar { 432 + width: 64px; 433 + height: 64px; 434 + border-radius: 50%; 435 + background: var(--gray-200); 436 + overflow: hidden; 437 + flex-shrink: 0; 438 + } 439 + 440 + .profile-avatar img { 441 + width: 100%; 442 + height: 100%; 443 + object-fit: cover; 444 + } 445 + 446 + .profile-name { 447 + font-size: 1.25rem; 448 + font-weight: 600; 449 + } 450 + 451 + .profile-handle { 452 + color: var(--gray-500); 453 + } 454 + ``` 455 + 456 + **Step 2: Commit** 457 + 458 + ```bash 459 + git add examples/02-following-feed/index.html 460 + git commit -m "feat(example): add post card and profile header CSS" 461 + ``` 462 + 463 + --- 464 + 465 + ## Task 8: Add renderPostsFeed Function 466 + 467 + **Files:** 468 + - Modify: `examples/02-following-feed/index.html` 469 + 470 + **Step 1: Add renderPostsFeed function** 471 + 472 + Add to the UI RENDERING section: 473 + 474 + ```javascript 475 + function renderPostsFeed(posts) { 476 + const container = document.getElementById('posts-feed'); 477 + 478 + if (posts.length === 0) { 479 + container.innerHTML = ` 480 + <div class="card"> 481 + <p class="loading">No posts found.</p> 482 + </div> 483 + `; 484 + return; 485 + } 486 + 487 + container.innerHTML = ` 488 + <div class="posts-list"> 489 + ${posts.map(post => { 490 + const profile = post.appBskyActorProfileByDid; 491 + const displayName = profile?.displayName || profile?.actorHandle || 'Unknown'; 492 + const handle = profile?.actorHandle || ''; 493 + const avatarUrl = profile?.avatar?.url; 494 + const images = post.embed?.images || []; 495 + 496 + return ` 497 + <div class="post-card"> 498 + <div class="post-header"> 499 + <div class="post-avatar"> 500 + ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="">` : ''} 501 + </div> 502 + <div class="post-author-info"> 503 + <div class="post-author-name">${escapeHtml(displayName)}</div> 504 + <div class="post-author-handle">@${escapeHtml(handle)}</div> 505 + </div> 506 + </div> 507 + <div class="post-text">${escapeHtml(post.text || '')}</div> 508 + ${images.length > 0 ? ` 509 + <div class="post-images ${images.length === 1 ? 'single' : 'multiple'}"> 510 + ${images.map(img => ` 511 + <img src="${escapeHtml(img.image?.url || '')}" alt="" loading="lazy"> 512 + `).join('')} 513 + </div> 514 + ` : ''} 515 + <div class="post-date">${formatDate(post.createdAt)}</div> 516 + </div> 517 + `; 518 + }).join('')} 519 + </div> 520 + `; 521 + } 522 + ``` 523 + 524 + **Step 2: Commit** 525 + 526 + ```bash 527 + git add examples/02-following-feed/index.html 528 + git commit -m "feat(example): add renderPostsFeed function" 529 + ``` 530 + 531 + --- 532 + 533 + ## Task 9: Add renderProfileHeader Function 534 + 535 + **Files:** 536 + - Modify: `examples/02-following-feed/index.html` 537 + 538 + **Step 1: Add renderProfileHeader function** 539 + 540 + Add to the UI RENDERING section: 541 + 542 + ```javascript 543 + function renderProfileHeader(profile) { 544 + const displayName = profile?.displayName || profile?.actorHandle || 'Unknown'; 545 + const handle = profile?.actorHandle || ''; 546 + const avatarUrl = profile?.avatar?.url; 547 + 548 + return ` 549 + <div class="profile-header"> 550 + <div class="profile-avatar"> 551 + ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="">` : ''} 552 + </div> 553 + <div> 554 + <div class="profile-name">${escapeHtml(displayName)}</div> 555 + <div class="profile-handle">@${escapeHtml(handle)}</div> 556 + </div> 557 + </div> 558 + `; 559 + } 560 + ``` 561 + 562 + **Step 2: Commit** 563 + 564 + ```bash 565 + git add examples/02-following-feed/index.html 566 + git commit -m "feat(example): add renderProfileHeader function" 567 + ``` 568 + 569 + --- 570 + 571 + ## Task 10: Add renderProfilePage Function 572 + 573 + **Files:** 574 + - Modify: `examples/02-following-feed/index.html` 575 + 576 + **Step 1: Add renderProfilePage function** 577 + 578 + Add to the UI RENDERING section: 579 + 580 + ```javascript 581 + async function renderProfilePage(handle) { 582 + const container = document.getElementById('posts-feed'); 583 + 584 + // Show loading state 585 + container.innerHTML = ` 586 + <div class="card"> 587 + <p class="loading">Loading profile...</p> 588 + </div> 589 + `; 590 + 591 + try { 592 + // Fetch profile and posts in parallel 593 + const [profile, posts] = await Promise.all([ 594 + fetchProfile(handle), 595 + fetchPosts(handle) 596 + ]); 597 + 598 + if (!profile) { 599 + container.innerHTML = ` 600 + <div class="card"> 601 + <p class="loading" style="color: var(--error-text);">Profile not found: @${escapeHtml(handle)}</p> 602 + </div> 603 + `; 604 + return; 605 + } 606 + 607 + // Render profile header + posts 608 + container.innerHTML = ` 609 + <div class="card"> 610 + ${renderProfileHeader(profile)} 611 + </div> 612 + `; 613 + 614 + renderPostsFeed(posts); 615 + } catch (error) { 616 + console.error('Failed to load profile:', error); 617 + container.innerHTML = ` 618 + <div class="card"> 619 + <p class="loading" style="color: var(--error-text);"> 620 + Failed to load profile. ${error.message} 621 + </p> 622 + </div> 623 + `; 624 + } 625 + } 626 + ``` 627 + 628 + **Step 2: Commit** 629 + 630 + ```bash 631 + git add examples/02-following-feed/index.html 632 + git commit -m "feat(example): add renderProfilePage function" 633 + ``` 634 + 635 + --- 636 + 637 + ## Task 11: Update renderLoginForm for Routing 638 + 639 + **Files:** 640 + - Modify: `examples/02-following-feed/index.html` 641 + 642 + **Step 1: Update renderLoginForm** 643 + 644 + Modify the existing `renderLoginForm` function to show a message when on profile page but not logged in: 645 + 646 + ```javascript 647 + function renderLoginForm() { 648 + const container = document.getElementById('auth-section'); 649 + const savedClientId = storage.get(STORAGE_KEYS.clientId) || ''; 650 + const profileHandle = router.getProfileHandle(); 651 + 652 + const message = profileHandle 653 + ? `<p style="margin-bottom: 1rem; color: var(--gray-700);">Login to view @${escapeHtml(profileHandle)}'s posts</p>` 654 + : ''; 655 + 656 + container.innerHTML = ` 657 + <div class="card"> 658 + ${message} 659 + <form class="login-form" onsubmit="handleLogin(event)"> 660 + <div class="form-group"> 661 + <label for="client-id">OAuth Client ID</label> 662 + <input 663 + type="text" 664 + id="client-id" 665 + placeholder="your-client-id" 666 + value="${escapeHtml(savedClientId)}" 667 + required 668 + > 669 + </div> 670 + <div class="form-group"> 671 + <label for="handle">Bluesky Handle</label> 672 + <input 673 + type="text" 674 + id="handle" 675 + placeholder="you.bsky.social" 676 + required 677 + > 678 + </div> 679 + <button type="submit" class="btn btn-primary">Login with Bluesky</button> 680 + </form> 681 + <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;"> 682 + Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a> 683 + </p> 684 + </div> 685 + `; 686 + } 687 + ``` 688 + 689 + **Step 2: Commit** 690 + 691 + ```bash 692 + git add examples/02-following-feed/index.html 693 + git commit -m "feat(example): update login form with profile context message" 694 + ``` 695 + 696 + --- 697 + 698 + ## Task 12: Update renderUserCard with Profile Link 699 + 700 + **Files:** 701 + - Modify: `examples/02-following-feed/index.html` 702 + 703 + **Step 1: Update renderUserCard** 704 + 705 + Modify to make the user info clickable to their profile: 706 + 707 + ```javascript 708 + function renderUserCard(viewer) { 709 + const container = document.getElementById('auth-section'); 710 + const displayName = viewer?.appBskyActorProfileByDid?.displayName || 'User'; 711 + const handle = viewer?.handle || 'unknown'; 712 + const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url; 713 + 714 + container.innerHTML = ` 715 + <div class="card user-card"> 716 + <a href="/profile/${escapeHtml(handle)}" class="user-info" onclick="event.preventDefault(); router.navigateTo('/profile/${escapeHtml(handle)}')"> 717 + <div class="user-avatar"> 718 + ${avatarUrl 719 + ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` 720 + : '👤'} 721 + </div> 722 + <div> 723 + <div class="user-name">${escapeHtml(displayName)}</div> 724 + <div class="user-handle">@${escapeHtml(handle)}</div> 725 + </div> 726 + </a> 727 + <button class="btn btn-secondary" onclick="logout()">Logout</button> 728 + </div> 729 + `; 730 + } 731 + ``` 732 + 733 + **Step 2: Add hover style for user-info link** 734 + 735 + Add to CSS: 736 + 737 + ```css 738 + a.user-info { 739 + text-decoration: none; 740 + color: inherit; 741 + } 742 + 743 + a.user-info:hover .user-name { 744 + color: var(--primary-500); 745 + } 746 + ``` 747 + 748 + **Step 3: Commit** 749 + 750 + ```bash 751 + git add examples/02-following-feed/index.html 752 + git commit -m "feat(example): make user card link to profile page" 753 + ``` 754 + 755 + --- 756 + 757 + ## Task 13: Create Main renderApp Function 758 + 759 + **Files:** 760 + - Modify: `examples/02-following-feed/index.html` 761 + 762 + **Step 1: Add renderApp function** 763 + 764 + Replace the `main` function with a new routing-aware `renderApp` function: 765 + 766 + ```javascript 767 + // ============================================================================= 768 + // MAIN APPLICATION 769 + // ============================================================================= 770 + 771 + let currentViewer = null; 772 + 773 + async function renderApp() { 774 + const profileHandle = router.getProfileHandle(); 775 + 776 + // Always render auth section first 777 + if (isLoggedIn()) { 778 + try { 779 + if (!currentViewer) { 780 + currentViewer = await fetchViewer(); 781 + } 782 + renderUserCard(currentViewer); 783 + } catch (error) { 784 + console.error('Failed to fetch viewer:', error); 785 + renderUserCard(null); 786 + } 787 + } else { 788 + renderLoginForm(); 789 + } 790 + 791 + // Clear posts feed 792 + document.getElementById('posts-feed').innerHTML = ''; 793 + 794 + // Route handling 795 + if (profileHandle) { 796 + // Profile page 797 + if (!isLoggedIn()) { 798 + document.getElementById('posts-feed').innerHTML = ` 799 + <div class="card"> 800 + <p class="loading">Please login to view profiles.</p> 801 + </div> 802 + `; 803 + return; 804 + } 805 + await renderProfilePage(profileHandle); 806 + } else { 807 + // Home page 808 + if (isLoggedIn() && currentViewer?.handle) { 809 + // Redirect logged-in users to their profile 810 + router.navigateTo(`/profile/${currentViewer.handle}`); 811 + } 812 + } 813 + } 814 + ``` 815 + 816 + **Step 2: Commit** 817 + 818 + ```bash 819 + git add examples/02-following-feed/index.html 820 + git commit -m "feat(example): add renderApp function with routing logic" 821 + ``` 822 + 823 + --- 824 + 825 + ## Task 14: Update Initialization and OAuth Callback 826 + 827 + **Files:** 828 + - Modify: `examples/02-following-feed/index.html` 829 + 830 + **Step 1: Replace main() with init()** 831 + 832 + Add new initialization function that handles OAuth callback and initial render: 833 + 834 + ```javascript 835 + async function init() { 836 + try { 837 + // Check if this is an OAuth callback 838 + const isCallback = await handleOAuthCallback(); 839 + if (isCallback) { 840 + console.log('OAuth callback handled successfully'); 841 + // Fetch viewer and redirect to profile 842 + const viewer = await fetchViewer(); 843 + currentViewer = viewer; 844 + if (viewer?.handle) { 845 + router.navigateTo(`/profile/${viewer.handle}`); 846 + return; 847 + } 848 + } 849 + } catch (error) { 850 + showError(`Authentication failed: ${error.message}`); 851 + storage.clear(); 852 + } 853 + 854 + // Render the app 855 + await renderApp(); 856 + } 857 + 858 + // Run on page load 859 + init(); 860 + ``` 861 + 862 + **Step 2: Remove the old main() call** 863 + 864 + Delete: 865 + ```javascript 866 + // Run on page load 867 + main(); 868 + ``` 869 + 870 + And delete the old `main()` function entirely. 871 + 872 + **Step 3: Commit** 873 + 874 + ```bash 875 + git add examples/02-following-feed/index.html 876 + git commit -m "feat(example): update init to handle OAuth callback and redirect to profile" 877 + ``` 878 + 879 + --- 880 + 881 + ## Task 15: Update README 882 + 883 + **Files:** 884 + - Modify: `examples/02-following-feed/README.md` 885 + 886 + **Step 1: Read current README** 887 + 888 + Check current content. 889 + 890 + **Step 2: Update README content** 891 + 892 + ```markdown 893 + # Following Feed Example 894 + 895 + A simple HTML example demonstrating how to view Bluesky profile posts using Quickslice's GraphQL API. 896 + 897 + ## Features 898 + 899 + - OAuth login with PKCE flow 900 + - Client-side routing (`/profile/{handle}`) 901 + - View any user's posts (excluding replies) 902 + - Display post text and embedded images 903 + 904 + ## Setup 905 + 906 + 1. Start the Quickslice server on `localhost:8080` 907 + 2. Open `index.html` in a browser 908 + 3. Enter your OAuth Client ID and Bluesky handle 909 + 4. After login, you'll be redirected to your profile page 910 + 911 + ## Routes 912 + 913 + - `/` - Home page (redirects to profile when logged in) 914 + - `/profile/{handle}` - View posts from a specific user 915 + 916 + ## GraphQL Query Used 917 + 918 + ```graphql 919 + query GetPosts($handle: String!) { 920 + appBskyFeedPost( 921 + sortBy: [{direction: DESC, field: createdAt}] 922 + where: { 923 + and: [ 924 + {actorHandle: {eq: $handle}}, 925 + {reply: {isNull: true}} 926 + ] 927 + } 928 + ) { 929 + edges { 930 + node { 931 + text 932 + createdAt 933 + appBskyActorProfileByDid { 934 + displayName 935 + actorHandle 936 + avatar { url } 937 + } 938 + embed { 939 + ... on AppBskyEmbedImages { 940 + images { 941 + image { url } 942 + } 943 + } 944 + } 945 + } 946 + } 947 + } 948 + } 949 + ``` 950 + 951 + ## Notes 952 + 953 + - Requires authentication to view profiles 954 + - Posts are sorted by creation date (newest first) 955 + - Replies are filtered out to show only original posts 956 + ``` 957 + 958 + **Step 3: Commit** 959 + 960 + ```bash 961 + git add examples/02-following-feed/README.md 962 + git commit -m "docs(example): update README for following feed example" 963 + ``` 964 + 965 + --- 966 + 967 + ## Task 16: Manual Testing 968 + 969 + **Step 1: Test login flow** 970 + 971 + 1. Open `examples/02-following-feed/index.html` in browser 972 + 2. Should see "Following Feed" header and login form 973 + 3. Enter client ID and handle, click login 974 + 4. Complete OAuth flow 975 + 5. Should redirect to `/profile/{your-handle}` 976 + 977 + **Step 2: Test profile page** 978 + 979 + 1. Should see your profile header (avatar, name, handle) 980 + 2. Should see your posts (newest first) 981 + 3. Should NOT see replies 982 + 4. Posts with images should display images 983 + 984 + **Step 3: Test viewing other profiles** 985 + 986 + 1. Manually navigate to `/profile/other-user.bsky.social` 987 + 2. Should see that user's profile and posts 988 + 989 + **Step 4: Test logout** 990 + 991 + 1. Click logout button 992 + 2. Should return to login form 993 + 3. URL should stay on profile page 994 + 4. Should see "Login to view @handle's posts" message 995 + 996 + --- 997 + 998 + ## Summary 999 + 1000 + | Task | Description | 1001 + |------|-------------| 1002 + | 1 | Update branding, remove emoji picker HTML | 1003 + | 2 | Remove emoji-related JavaScript constants and functions | 1004 + | 3 | Remove statusphere feed code and CSS | 1005 + | 4 | Add client-side routing utilities | 1006 + | 5 | Add fetchPosts function | 1007 + | 6 | Add fetchProfile function | 1008 + | 7 | Add post card CSS | 1009 + | 8 | Add renderPostsFeed function | 1010 + | 9 | Add renderProfileHeader function | 1011 + | 10 | Add renderProfilePage function | 1012 + | 11 | Update renderLoginForm for routing context | 1013 + | 12 | Update renderUserCard with profile link | 1014 + | 13 | Create main renderApp function | 1015 + | 14 | Update initialization and OAuth callback | 1016 + | 15 | Update README | 1017 + | 16 | Manual testing |
+63
examples/02-following-feed-wip/README.md
··· 1 + # Following Feed Example 2 + 3 + A simple HTML example demonstrating how to view Bluesky profile posts using Quickslice's GraphQL API. 4 + 5 + ## Features 6 + 7 + - OAuth login with PKCE flow 8 + - Client-side routing (`/profile/{handle}`) 9 + - View any user's posts (excluding replies) 10 + - Display post text and embedded images 11 + 12 + ## Setup 13 + 14 + 1. Start the Quickslice server on `localhost:8080` 15 + 2. Open `index.html` in a browser 16 + 3. Enter your OAuth Client ID and Bluesky handle 17 + 4. After login, you'll be redirected to your profile page 18 + 19 + ## Routes 20 + 21 + - `/` - Home page (redirects to profile when logged in) 22 + - `/profile/{handle}` - View posts from a specific user 23 + 24 + ## GraphQL Query Used 25 + 26 + ```graphql 27 + query GetPosts($handle: String!) { 28 + appBskyFeedPost( 29 + sortBy: [{direction: DESC, field: createdAt}] 30 + where: { 31 + and: [ 32 + {actorHandle: {eq: $handle}}, 33 + {reply: {isNull: true}} 34 + ] 35 + } 36 + ) { 37 + edges { 38 + node { 39 + text 40 + createdAt 41 + appBskyActorProfileByDid { 42 + displayName 43 + actorHandle 44 + avatar { url } 45 + } 46 + embed { 47 + ... on AppBskyEmbedImages { 48 + images { 49 + image { url } 50 + } 51 + } 52 + } 53 + } 54 + } 55 + } 56 + } 57 + ``` 58 + 59 + ## Notes 60 + 61 + - Requires authentication to view profiles 62 + - Posts are sorted by creation date (newest first) 63 + - Replies are filtered out to show only original posts
-75
examples/02-following-feed/README.md
··· 1 - # Statusphere HTML Example 2 - 3 - A single-file HTML example demonstrating quickslice's GraphQL API with OAuth authentication. 4 - 5 - ## Features 6 - 7 - - OAuth PKCE authentication flow 8 - - Post status updates (emoji) 9 - - View recent statuses from the network 10 - - Display user profiles 11 - 12 - ## Prerequisites 13 - 14 - 1. Quickslice server running at `http://localhost:8080` 15 - 2. A registered OAuth client 16 - 17 - ## Setup 18 - 19 - ### 1. Start Quickslice 20 - 21 - ```bash 22 - cd /path/to/quickslice 23 - make run 24 - ``` 25 - 26 - ### 2. Register an OAuth Client 27 - 28 - Navigate to the admin settings page at `http://localhost:8080/admin/settings` and register a new OAuth client with: 29 - 30 - - **Name:** Statusphere HTML Example 31 - - **Token Endpoint Auth Method:** Public 32 - - **Redirect URIs:** `http://127.0.0.1:3000/` 33 - 34 - **Important:** Set the redirect URI to match where you'll serve this HTML file. 35 - 36 - ### 3. Serve the HTML File 37 - 38 - ```bash 39 - npx http-server . -p 3000 40 - # Open http://127.0.0.1:3000 41 - ``` 42 - 43 - ### 4. Login 44 - 45 - 1. Enter your OAuth Client ID 46 - 2. Enter your Bluesky handle (e.g., `you.bsky.social`) 47 - 3. Click "Login with Bluesky" 48 - 4. Authorize the app on your AT Protocol PDS 49 - 5. You'll be redirected back and logged in 50 - 51 - ## Usage 52 - 53 - - Click any emoji to set your status 54 - - View recent statuses from the network 55 - - Click "Logout" to clear your session 56 - 57 - ## Security Notes 58 - 59 - - Tokens are stored in `sessionStorage` (cleared when tab closes) 60 - - No external dependencies - all code is inline 61 - - Uses PKCE for secure OAuth flow 62 - - CSP header restricts connections to localhost:8080 63 - 64 - ## Troubleshooting 65 - 66 - **"Failed to load statuses"** 67 - - Ensure quickslice server is running at localhost:8080 68 - 69 - **OAuth redirect fails** 70 - - Verify redirect URI matches exactly in OAuth client config 71 - - Check that the client ID is correct 72 - 73 - **Can't post status** 74 - - Ensure you're logged in (session may have expired) 75 - - Check browser console for error details
+360 -252
examples/02-following-feed/index.html examples/02-following-feed-wip/index.html
··· 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 6 <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src http://localhost:8080; img-src 'self' https: data:;"> 7 - <title>Statusphere</title> 7 + <title>Following Feed</title> 8 8 <style> 9 9 /* CSS Reset */ 10 10 *, *::before, *::after { ··· 183 183 color: var(--gray-500); 184 184 } 185 185 186 - /* Emoji Picker */ 187 - .emoji-grid { 188 - display: grid; 189 - grid-template-columns: repeat(9, 1fr); 190 - gap: 0.5rem; 186 + a.user-info { 187 + text-decoration: none; 188 + color: inherit; 189 + } 190 + 191 + a.user-info:hover .user-name { 192 + color: var(--primary-500); 191 193 } 192 194 193 - .emoji-btn { 194 - width: 100%; 195 - aspect-ratio: 1; 196 - font-size: 1.5rem; 197 - border: 2px solid var(--border-color); 198 - border-radius: 50%; 195 + /* Post Cards */ 196 + .posts-list { 197 + display: flex; 198 + flex-direction: column; 199 + gap: 1rem; 200 + } 201 + 202 + .post-card { 199 203 background: white; 200 - cursor: pointer; 201 - transition: all 0.15s; 204 + border-radius: 0.5rem; 205 + padding: 1rem; 206 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 207 + } 208 + 209 + .post-header { 202 210 display: flex; 203 211 align-items: center; 204 - justify-content: center; 212 + gap: 0.75rem; 213 + margin-bottom: 0.75rem; 205 214 } 206 215 207 - .emoji-btn:hover { 208 - background: rgba(0, 120, 255, 0.1); 209 - border-color: var(--primary-400); 216 + .post-avatar { 217 + width: 40px; 218 + height: 40px; 219 + border-radius: 50%; 220 + background: var(--gray-200); 221 + overflow: hidden; 222 + flex-shrink: 0; 210 223 } 211 224 212 - .emoji-btn.selected { 213 - border-color: var(--primary-500); 214 - box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.2); 225 + .post-avatar img { 226 + width: 100%; 227 + height: 100%; 228 + object-fit: cover; 215 229 } 216 230 217 - .emoji-btn:disabled { 218 - opacity: 0.5; 219 - cursor: not-allowed; 231 + .post-author-info { 232 + flex: 1; 233 + min-width: 0; 220 234 } 221 235 222 - .emoji-btn:disabled:hover { 223 - background: white; 224 - border-color: var(--border-color); 236 + .post-author-name { 237 + font-weight: 600; 238 + color: var(--gray-900); 239 + white-space: nowrap; 240 + overflow: hidden; 241 + text-overflow: ellipsis; 225 242 } 226 243 227 - /* Status Feed */ 228 - .feed-title { 229 - font-size: 1.125rem; 230 - font-weight: 600; 231 - margin-bottom: 1rem; 232 - color: var(--gray-700); 244 + .post-author-handle { 245 + font-size: 0.875rem; 246 + color: var(--gray-500); 233 247 } 234 248 235 - .status-list { 236 - list-style: none; 237 - padding: 0; 249 + .post-text { 250 + color: var(--gray-700); 251 + line-height: 1.5; 252 + white-space: pre-wrap; 253 + word-break: break-word; 238 254 } 239 255 240 - .status-item { 241 - position: relative; 242 - padding-left: 2rem; 243 - padding-bottom: 1.5rem; 256 + .post-images { 257 + display: grid; 258 + gap: 0.5rem; 259 + margin-top: 0.75rem; 244 260 } 245 261 246 - .status-item::before { 247 - content: ""; 248 - position: absolute; 249 - left: 0.75rem; 250 - top: 1.5rem; 251 - bottom: 0; 252 - width: 2px; 253 - background: var(--border-color); 262 + .post-images.single { 263 + grid-template-columns: 1fr; 254 264 } 255 265 256 - .status-item:last-child::before { 257 - display: none; 266 + .post-images.multiple { 267 + grid-template-columns: repeat(2, 1fr); 258 268 } 259 269 260 - .status-item:last-child { 261 - padding-bottom: 0; 270 + .post-images img { 271 + width: 100%; 272 + border-radius: 0.5rem; 273 + max-height: 300px; 274 + object-fit: cover; 262 275 } 263 276 264 - .status-emoji { 265 - position: absolute; 266 - left: 0; 267 - top: 0; 268 - font-size: 1.5rem; 277 + .post-date { 278 + font-size: 0.75rem; 279 + color: var(--gray-500); 280 + margin-top: 0.75rem; 269 281 } 270 282 271 - .status-content { 272 - padding-top: 0.25rem; 283 + /* Profile Header */ 284 + .profile-header { 285 + display: flex; 286 + align-items: center; 287 + gap: 1rem; 288 + margin-bottom: 1.5rem; 273 289 } 274 290 275 - .status-author { 276 - color: var(--primary-500); 277 - text-decoration: none; 278 - font-weight: 500; 291 + .profile-avatar { 292 + width: 64px; 293 + height: 64px; 294 + border-radius: 50%; 295 + background: var(--gray-200); 296 + overflow: hidden; 297 + flex-shrink: 0; 279 298 } 280 299 281 - .status-author:hover { 282 - text-decoration: underline; 300 + .profile-avatar img { 301 + width: 100%; 302 + height: 100%; 303 + object-fit: cover; 283 304 } 284 305 285 - .status-text { 286 - color: var(--gray-700); 306 + .profile-name { 307 + font-size: 1.25rem; 308 + font-weight: 600; 287 309 } 288 310 289 - .status-date { 290 - font-size: 0.875rem; 311 + .profile-handle { 291 312 color: var(--gray-500); 292 313 } 293 314 ··· 349 370 <body> 350 371 <div id="app"> 351 372 <header> 352 - <h1>Statusphere</h1> 353 - <p class="tagline">Set your status on the Atmosphere</p> 373 + <h1>Following Feed</h1> 374 + <p class="tagline">View posts on the Atmosphere</p> 354 375 </header> 355 376 <main> 356 377 <div id="auth-section"></div> 357 - <div id="emoji-picker"></div> 358 - <div id="status-feed"></div> 378 + <div id="posts-feed"></div> 359 379 </main> 360 380 <div id="error-banner" class="hidden"></div> 361 381 </div> ··· 367 387 const GRAPHQL_URL = 'http://localhost:8080/graphql'; 368 388 const OAUTH_AUTHORIZE_URL = 'http://localhost:8080/oauth/authorize'; 369 389 const OAUTH_TOKEN_URL = 'http://localhost:8080/oauth/token'; 370 - 371 - const EMOJIS = [ 372 - '👍', '👎', '💙', '😧', '😤', '🙃', '😉', '😎', '🤩', 373 - '🥳', '😭', '😱', '🥺', '😡', '💀', '🤖', '👻', '👽', 374 - '🎃', '🤡', '💩', '🔥', '⭐', '🌈', '🍕', '🎉', '💯' 375 - ]; 376 390 377 391 const STORAGE_KEYS = { 378 392 accessToken: 'qs_access_token', ··· 403 417 }; 404 418 405 419 // ============================================================================= 420 + // ROUTING 421 + // ============================================================================= 422 + 423 + const router = { 424 + getPath() { 425 + return window.location.pathname; 426 + }, 427 + 428 + getProfileHandle() { 429 + const match = this.getPath().match(/^\/profile\/(.+)$/); 430 + return match ? decodeURIComponent(match[1]) : null; 431 + }, 432 + 433 + navigateTo(path) { 434 + window.history.pushState({}, '', path); 435 + renderApp(); 436 + }, 437 + 438 + isProfilePage() { 439 + return this.getPath().startsWith('/profile/'); 440 + } 441 + }; 442 + 443 + // Handle browser back/forward 444 + window.addEventListener('popstate', () => renderApp()); 445 + 446 + // ============================================================================= 406 447 // PKCE UTILITIES 407 448 // ============================================================================= 408 449 ··· 451 492 storage.set(STORAGE_KEYS.oauthState, state); 452 493 storage.set(STORAGE_KEYS.clientId, clientId); 453 494 454 - // Build redirect URI (current page without query params) 455 - const redirectUri = window.location.origin + window.location.pathname; 495 + // Build redirect URI (always use origin root for SPA compatibility) 496 + const redirectUri = window.location.origin + '/'; 456 497 457 498 // Build authorization URL 458 499 const params = new URLSearchParams({ ··· 491 532 // Get stored values 492 533 const codeVerifier = storage.get(STORAGE_KEYS.codeVerifier); 493 534 const clientId = storage.get(STORAGE_KEYS.clientId); 494 - const redirectUri = window.location.origin + window.location.pathname; 535 + const redirectUri = window.location.origin + '/'; 495 536 496 537 if (!codeVerifier || !clientId) { 497 538 throw new Error('Missing OAuth session data'); ··· 597 638 // DATA FETCHING 598 639 // ============================================================================= 599 640 600 - async function fetchStatuses() { 601 - const query = ` 602 - query GetStatuses { 603 - xyzStatusphereStatus( 604 - first: 20 605 - sortBy: [{ field: "createdAt", direction: DESC }] 606 - ) { 607 - edges { 608 - node { 609 - uri 610 - did 611 - status 612 - createdAt 613 - appBskyActorProfileByDid { 614 - actorHandle 615 - displayName 616 - } 617 - } 618 - } 619 - } 620 - } 621 - `; 622 - 623 - const data = await graphqlQuery(query); 624 - return data.xyzStatusphereStatus?.edges?.map(e => e.node) || []; 625 - } 626 - 627 641 async function fetchViewer() { 628 642 const query = ` 629 643 query { ··· 642 656 return data?.viewer; 643 657 } 644 658 645 - async function postStatus(emoji) { 646 - const mutation = ` 647 - mutation CreateStatus($status: String!, $createdAt: DateTime!) { 648 - createXyzStatusphereStatus( 649 - input: { status: $status, createdAt: $createdAt } 659 + async function fetchPosts(handle) { 660 + const query = ` 661 + query GetPosts($handle: String!) { 662 + appBskyFeedPost( 663 + sortBy: [{direction: DESC, field: createdAt}] 664 + where: { 665 + and: [ 666 + {actorHandle: {eq: $handle}}, 667 + {reply: {isNull: true}} 668 + ] 669 + } 650 670 ) { 651 - uri 652 - status 653 - createdAt 671 + edges { 672 + node { 673 + uri 674 + text 675 + createdAt 676 + appBskyActorProfileByDid { 677 + displayName 678 + actorHandle 679 + avatar { 680 + url 681 + } 682 + } 683 + embed { 684 + ... on AppBskyEmbedImages { 685 + images { 686 + image { 687 + url 688 + } 689 + } 690 + } 691 + } 692 + } 693 + } 654 694 } 655 695 } 656 696 `; 657 697 658 - const variables = { 659 - status: emoji, 660 - createdAt: new Date().toISOString() 661 - }; 698 + const data = await graphqlQuery(query, { handle }, true); 699 + return data.appBskyFeedPost?.edges?.map(e => e.node) || []; 700 + } 662 701 663 - const data = await graphqlQuery(mutation, variables, true); 664 - return data.createXyzStatusphereStatus; 702 + async function fetchProfile(handle) { 703 + const query = ` 704 + query GetProfile($handle: String!) { 705 + appBskyActorProfile( 706 + where: {actorHandle: {eq: $handle}} 707 + first: 1 708 + ) { 709 + edges { 710 + node { 711 + did 712 + actorHandle 713 + displayName 714 + avatar { 715 + url 716 + } 717 + } 718 + } 719 + } 720 + } 721 + `; 722 + 723 + const data = await graphqlQuery(query, { handle }, true); 724 + const edges = data.appBskyActorProfile?.edges || []; 725 + return edges.length > 0 ? edges[0].node : null; 665 726 } 666 727 667 728 // ============================================================================= ··· 703 764 }); 704 765 } 705 766 767 + function renderPostsFeed(posts) { 768 + const container = document.getElementById('posts-feed'); 769 + 770 + if (posts.length === 0) { 771 + container.innerHTML = ` 772 + <div class="card"> 773 + <p class="loading">No posts found.</p> 774 + </div> 775 + `; 776 + return; 777 + } 778 + 779 + container.innerHTML = ` 780 + <div class="posts-list"> 781 + ${posts.map(post => { 782 + const profile = post.appBskyActorProfileByDid; 783 + const displayName = profile?.displayName || profile?.actorHandle || 'Unknown'; 784 + const handle = profile?.actorHandle || ''; 785 + const avatarUrl = profile?.avatar?.url; 786 + const images = post.embed?.images || []; 787 + 788 + return ` 789 + <div class="post-card"> 790 + <div class="post-header"> 791 + <div class="post-avatar"> 792 + ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="">` : ''} 793 + </div> 794 + <div class="post-author-info"> 795 + <div class="post-author-name">${escapeHtml(displayName)}</div> 796 + <div class="post-author-handle">@${escapeHtml(handle)}</div> 797 + </div> 798 + </div> 799 + <div class="post-text">${escapeHtml(post.text || '')}</div> 800 + ${images.length > 0 ? ` 801 + <div class="post-images ${images.length === 1 ? 'single' : 'multiple'}"> 802 + ${images.map(img => ` 803 + <img src="${escapeHtml(img.image?.url || '')}" alt="" loading="lazy"> 804 + `).join('')} 805 + </div> 806 + ` : ''} 807 + <div class="post-date">${formatDate(post.createdAt)}</div> 808 + </div> 809 + `; 810 + }).join('')} 811 + </div> 812 + `; 813 + } 814 + 815 + function renderProfileHeader(profile) { 816 + const displayName = profile?.displayName || profile?.actorHandle || 'Unknown'; 817 + const handle = profile?.actorHandle || ''; 818 + const avatarUrl = profile?.avatar?.url; 819 + 820 + return ` 821 + <div class="profile-header"> 822 + <div class="profile-avatar"> 823 + ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="">` : ''} 824 + </div> 825 + <div> 826 + <div class="profile-name">${escapeHtml(displayName)}</div> 827 + <div class="profile-handle">@${escapeHtml(handle)}</div> 828 + </div> 829 + </div> 830 + `; 831 + } 832 + 833 + async function renderProfilePage(handle) { 834 + const container = document.getElementById('posts-feed'); 835 + 836 + // Show loading state 837 + container.innerHTML = ` 838 + <div class="card"> 839 + <p class="loading">Loading profile...</p> 840 + </div> 841 + `; 842 + 843 + try { 844 + // Fetch profile and posts in parallel 845 + const [profile, posts] = await Promise.all([ 846 + fetchProfile(handle), 847 + fetchPosts(handle) 848 + ]); 849 + 850 + if (!profile) { 851 + container.innerHTML = ` 852 + <div class="card"> 853 + <p class="loading" style="color: var(--error-text);">Profile not found: @${escapeHtml(handle)}</p> 854 + </div> 855 + `; 856 + return; 857 + } 858 + 859 + // Render profile header + posts 860 + container.innerHTML = ` 861 + <div class="card"> 862 + ${renderProfileHeader(profile)} 863 + </div> 864 + `; 865 + 866 + renderPostsFeed(posts); 867 + } catch (error) { 868 + console.error('Failed to load profile:', error); 869 + container.innerHTML = ` 870 + <div class="card"> 871 + <p class="loading" style="color: var(--error-text);"> 872 + Failed to load profile. ${error.message} 873 + </p> 874 + </div> 875 + `; 876 + } 877 + } 878 + 706 879 function renderLoginForm() { 707 880 const container = document.getElementById('auth-section'); 708 881 const savedClientId = storage.get(STORAGE_KEYS.clientId) || ''; 882 + const profileHandle = router.getProfileHandle(); 883 + 884 + const message = profileHandle 885 + ? `<p style="margin-bottom: 1rem; color: var(--gray-700);">Login to view @${escapeHtml(profileHandle)}'s posts</p>` 886 + : ''; 709 887 710 888 container.innerHTML = ` 711 889 <div class="card"> 890 + ${message} 712 891 <form class="login-form" onsubmit="handleLogin(event)"> 713 892 <div class="form-group"> 714 893 <label for="client-id">OAuth Client ID</label> ··· 738 917 `; 739 918 } 740 919 741 - function renderUserCard(profile) { 920 + function renderUserCard(viewer) { 742 921 const container = document.getElementById('auth-section'); 743 - const displayName = profile?.displayName || 'User'; 744 - const handle = profile?.actorHandle || 'unknown'; 745 - const avatarUrl = profile?.avatar?.url; 922 + const displayName = viewer?.appBskyActorProfileByDid?.displayName || 'User'; 923 + const handle = viewer?.handle || 'unknown'; 924 + const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url; 746 925 747 926 container.innerHTML = ` 748 927 <div class="card user-card"> 749 - <div class="user-info"> 928 + <a href="/profile/${escapeHtml(handle)}" class="user-info" onclick="event.preventDefault(); router.navigateTo('/profile/${escapeHtml(handle)}')"> 750 929 <div class="user-avatar"> 751 930 ${avatarUrl 752 931 ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` 753 932 : '👤'} 754 933 </div> 755 934 <div> 756 - <div class="user-name">Hi, ${escapeHtml(displayName)}!</div> 935 + <div class="user-name">${escapeHtml(displayName)}</div> 757 936 <div class="user-handle">@${escapeHtml(handle)}</div> 758 937 </div> 759 - </div> 938 + </a> 760 939 <button class="btn btn-secondary" onclick="logout()">Logout</button> 761 940 </div> 762 941 `; 763 942 } 764 943 765 - function renderEmojiPicker(currentStatus, enabled = true) { 766 - const container = document.getElementById('emoji-picker'); 767 - 768 - container.innerHTML = ` 769 - <div class="card"> 770 - <div class="emoji-grid"> 771 - ${EMOJIS.map(emoji => ` 772 - <button 773 - class="emoji-btn ${emoji === currentStatus ? 'selected' : ''}" 774 - onclick="selectStatus('${emoji}')" 775 - ${!enabled ? 'disabled' : ''} 776 - title="${enabled ? 'Set status' : 'Login to set status'}" 777 - > 778 - ${emoji} 779 - </button> 780 - `).join('')} 781 - </div> 782 - </div> 783 - `; 784 - } 785 - 786 - function renderStatusFeed(statuses) { 787 - const container = document.getElementById('status-feed'); 788 - 789 - if (statuses.length === 0) { 790 - container.innerHTML = ` 791 - <div class="card"> 792 - <p class="loading">No statuses yet. Be the first to post!</p> 793 - </div> 794 - `; 795 - return; 796 - } 797 - 798 - container.innerHTML = ` 799 - <div class="card"> 800 - <h2 class="feed-title">Recent Statuses</h2> 801 - <ul class="status-list"> 802 - ${statuses.map(status => { 803 - const handle = status.appBskyActorProfileByDid?.actorHandle || status.did; 804 - const displayHandle = handle.startsWith('did:') ? handle.substring(0, 20) + '...' : handle; 805 - const profileUrl = handle.startsWith('did:') 806 - ? `https://bsky.app/profile/${status.did}` 807 - : `https://bsky.app/profile/${handle}`; 808 - 809 - return ` 810 - <li class="status-item"> 811 - <span class="status-emoji">${status.status}</span> 812 - <div class="status-content"> 813 - <span class="status-text"> 814 - <a href="${profileUrl}" target="_blank" class="status-author">@${escapeHtml(displayHandle)}</a> 815 - is feeling ${status.status} 816 - </span> 817 - <div class="status-date">${formatDate(status.createdAt)}</div> 818 - </div> 819 - </li> 820 - `; 821 - }).join('')} 822 - </ul> 823 - </div> 824 - `; 825 - } 826 - 827 944 function renderLoading(container) { 828 945 document.getElementById(container).innerHTML = ` 829 946 <div class="card"> ··· 854 971 } 855 972 } 856 973 857 - async function selectStatus(emoji) { 858 - if (!isLoggedIn()) { 859 - showError('Please login to set your status'); 860 - return; 861 - } 862 - 863 - try { 864 - // Disable buttons while posting 865 - document.querySelectorAll('.emoji-btn').forEach(btn => btn.disabled = true); 866 - 867 - await postStatus(emoji); 868 - 869 - // Refresh the page to show new status 870 - window.location.reload(); 871 - } catch (error) { 872 - showError(`Failed to post status: ${error.message}`); 873 - // Re-enable buttons 874 - document.querySelectorAll('.emoji-btn').forEach(btn => btn.disabled = false); 875 - } 876 - } 877 - 878 974 // ============================================================================= 879 - // MAIN INITIALIZATION 975 + // MAIN APPLICATION 880 976 // ============================================================================= 881 977 882 - async function main() { 883 - try { 884 - // Check if this is an OAuth callback 885 - const isCallback = await handleOAuthCallback(); 886 - if (isCallback) { 887 - console.log('OAuth callback handled successfully'); 888 - } 889 - } catch (error) { 890 - showError(`Authentication failed: ${error.message}`); 891 - storage.clear(); 892 - } 978 + let currentViewer = null; 979 + 980 + async function renderApp() { 981 + const profileHandle = router.getProfileHandle(); 893 982 894 - // Render auth section 983 + // Always render auth section first 895 984 if (isLoggedIn()) { 896 985 try { 897 - const viewer = await fetchViewer(); 898 - if (viewer) { 899 - const profile = { 900 - did: viewer.did, 901 - actorHandle: viewer.handle, 902 - displayName: viewer.appBskyActorProfileByDid?.displayName, 903 - avatar: viewer.appBskyActorProfileByDid?.avatar, 904 - }; 905 - renderUserCard(profile); 906 - } else { 907 - renderUserCard(null); 986 + if (!currentViewer) { 987 + currentViewer = await fetchViewer(); 908 988 } 989 + renderUserCard(currentViewer); 909 990 } catch (error) { 910 991 console.error('Failed to fetch viewer:', error); 911 992 renderUserCard(null); ··· 914 995 renderLoginForm(); 915 996 } 916 997 917 - // Render emoji picker (enabled only if logged in) 918 - renderEmojiPicker(null, isLoggedIn()); 998 + // Clear posts feed 999 + document.getElementById('posts-feed').innerHTML = ''; 919 1000 920 - // Fetch and render statuses 921 - renderLoading('status-feed'); 1001 + // Route handling 1002 + if (profileHandle) { 1003 + // Profile page 1004 + if (!isLoggedIn()) { 1005 + document.getElementById('posts-feed').innerHTML = ` 1006 + <div class="card"> 1007 + <p class="loading">Please login to view profiles.</p> 1008 + </div> 1009 + `; 1010 + return; 1011 + } 1012 + await renderProfilePage(profileHandle); 1013 + } else { 1014 + // Home page 1015 + if (isLoggedIn() && currentViewer?.handle) { 1016 + // Redirect logged-in users to their profile 1017 + router.navigateTo(`/profile/${currentViewer.handle}`); 1018 + } 1019 + } 1020 + } 1021 + 1022 + async function init() { 922 1023 try { 923 - const statuses = await fetchStatuses(); 924 - renderStatusFeed(statuses); 1024 + // Check if this is an OAuth callback 1025 + const isCallback = await handleOAuthCallback(); 1026 + if (isCallback) { 1027 + console.log('OAuth callback handled successfully'); 1028 + // Fetch viewer and redirect to profile 1029 + const viewer = await fetchViewer(); 1030 + currentViewer = viewer; 1031 + if (viewer?.handle) { 1032 + router.navigateTo(`/profile/${viewer.handle}`); 1033 + return; 1034 + } 1035 + } 925 1036 } catch (error) { 926 - console.error('Failed to fetch statuses:', error); 927 - document.getElementById('status-feed').innerHTML = ` 928 - <div class="card"> 929 - <p class="loading" style="color: var(--error-text);"> 930 - Failed to load statuses. Is the quickslice server running at localhost:8080? 931 - </p> 932 - </div> 933 - `; 1037 + showError(`Authentication failed: ${error.message}`); 1038 + storage.clear(); 934 1039 } 1040 + 1041 + // Render the app 1042 + await renderApp(); 935 1043 } 936 1044 937 1045 // Run on page load 938 - main(); 1046 + init(); 939 1047 </script> 940 1048 </body> 941 1049 </html>
examples/02-following-feed/lexicons.json examples/02-following-feed-wip/lexicons.json
examples/02-following-feed/lexicons.zip

This is a binary file and will not be displayed.

examples/02-following-feed/lexicons/app/bsky/actor/defs.json examples/02-following-feed-wip/lexicons/app/bsky/actor/defs.json
examples/02-following-feed/lexicons/app/bsky/actor/profile.json examples/02-following-feed-wip/lexicons/app/bsky/actor/profile.json
examples/02-following-feed/lexicons/app/bsky/embed/defs.json examples/02-following-feed-wip/lexicons/app/bsky/embed/defs.json
examples/02-following-feed/lexicons/app/bsky/embed/external.json examples/02-following-feed-wip/lexicons/app/bsky/embed/external.json
examples/02-following-feed/lexicons/app/bsky/embed/images.json examples/02-following-feed-wip/lexicons/app/bsky/embed/images.json
examples/02-following-feed/lexicons/app/bsky/embed/record.json examples/02-following-feed-wip/lexicons/app/bsky/embed/record.json
examples/02-following-feed/lexicons/app/bsky/embed/recordWithMedia.json examples/02-following-feed-wip/lexicons/app/bsky/embed/recordWithMedia.json
examples/02-following-feed/lexicons/app/bsky/embed/video.json examples/02-following-feed-wip/lexicons/app/bsky/embed/video.json
examples/02-following-feed/lexicons/app/bsky/feed/defs.json examples/02-following-feed-wip/lexicons/app/bsky/feed/defs.json
examples/02-following-feed/lexicons/app/bsky/feed/post.json examples/02-following-feed-wip/lexicons/app/bsky/feed/post.json
examples/02-following-feed/lexicons/app/bsky/feed/postgate.json examples/02-following-feed-wip/lexicons/app/bsky/feed/postgate.json
examples/02-following-feed/lexicons/app/bsky/feed/threadgate.json examples/02-following-feed-wip/lexicons/app/bsky/feed/threadgate.json
examples/02-following-feed/lexicons/app/bsky/graph/defs.json examples/02-following-feed-wip/lexicons/app/bsky/graph/defs.json
examples/02-following-feed/lexicons/app/bsky/graph/follow.json examples/02-following-feed-wip/lexicons/app/bsky/graph/follow.json
examples/02-following-feed/lexicons/app/bsky/labeler/defs.json examples/02-following-feed-wip/lexicons/app/bsky/labeler/defs.json
examples/02-following-feed/lexicons/app/bsky/notification/defs.json examples/02-following-feed-wip/lexicons/app/bsky/notification/defs.json
examples/02-following-feed/lexicons/app/bsky/richtext/facet.json examples/02-following-feed-wip/lexicons/app/bsky/richtext/facet.json
examples/02-following-feed/lexicons/com/atproto/label/defs.json examples/02-following-feed-wip/lexicons/com/atproto/label/defs.json
examples/02-following-feed/lexicons/com/atproto/moderation/defs.json examples/02-following-feed-wip/lexicons/com/atproto/moderation/defs.json
examples/02-following-feed/lexicons/com/atproto/repo/strongRef.json examples/02-following-feed-wip/lexicons/com/atproto/repo/strongRef.json