WIP PWA for Grain

docs: add followers/following pages implementation plan

+951
+951
docs/plans/2025-12-26-followers-following-pages.md
··· 1 + # Followers/Following Pages Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add `/profile/{handle}/followers` and `/profile/{handle}/following` pages showing lists of user profiles with infinite scroll. 6 + 7 + **Architecture:** Create a reusable `grain-profile-card` molecule, two new page components that use infinite scroll (like timeline), extend `grain-api.js` with follower/following queries, and make profile stats clickable. 8 + 9 + **Tech Stack:** Lit web components, GraphQL via Quickslice API, existing design system (CSS variables, grain-feed-layout) 10 + 11 + --- 12 + 13 + ## Task 1: Create grain-profile-card Molecule 14 + 15 + **Files:** 16 + - Create: `src/components/molecules/grain-profile-card.js` 17 + 18 + **Step 1: Create the component file** 19 + 20 + ```javascript 21 + import { LitElement, html, css } from 'lit'; 22 + import { router } from '../../router.js'; 23 + import '../atoms/grain-avatar.js'; 24 + 25 + export class GrainProfileCard extends LitElement { 26 + static properties = { 27 + handle: { type: String }, 28 + displayName: { type: String }, 29 + description: { type: String }, 30 + avatarUrl: { type: String } 31 + }; 32 + 33 + static styles = css` 34 + :host { 35 + display: block; 36 + } 37 + a { 38 + display: flex; 39 + gap: var(--space-sm); 40 + padding: var(--space-sm); 41 + text-decoration: none; 42 + color: inherit; 43 + border-radius: 8px; 44 + } 45 + a:hover { 46 + background: var(--color-bg-secondary); 47 + } 48 + .info { 49 + display: flex; 50 + flex-direction: column; 51 + min-width: 0; 52 + flex: 1; 53 + } 54 + .handle { 55 + font-size: var(--font-size-sm); 56 + font-weight: var(--font-weight-semibold); 57 + color: var(--color-text-primary); 58 + } 59 + .name { 60 + font-size: var(--font-size-xs); 61 + color: var(--color-text-secondary); 62 + } 63 + .description { 64 + font-size: var(--font-size-xs); 65 + color: var(--color-text-secondary); 66 + margin-top: var(--space-xs); 67 + display: -webkit-box; 68 + -webkit-line-clamp: 2; 69 + -webkit-box-orient: vertical; 70 + overflow: hidden; 71 + } 72 + `; 73 + 74 + #handleClick(e) { 75 + e.preventDefault(); 76 + router.push(`/profile/${this.handle}`); 77 + } 78 + 79 + render() { 80 + return html` 81 + <a href="/profile/${this.handle}" @click=${this.#handleClick}> 82 + <grain-avatar 83 + src=${this.avatarUrl || ''} 84 + alt=${this.handle || ''} 85 + size="md" 86 + ></grain-avatar> 87 + <div class="info"> 88 + <span class="handle">${this.handle}</span> 89 + ${this.displayName ? html`<span class="name">${this.displayName}</span>` : ''} 90 + ${this.description ? html`<p class="description">${this.description}</p>` : ''} 91 + </div> 92 + </a> 93 + `; 94 + } 95 + } 96 + 97 + customElements.define('grain-profile-card', GrainProfileCard); 98 + ``` 99 + 100 + **Step 2: Verify component renders** 101 + 102 + Run: `npm run dev` 103 + Manually test by temporarily importing in another component. 104 + 105 + **Step 3: Commit** 106 + 107 + ```bash 108 + git add src/components/molecules/grain-profile-card.js 109 + git commit -m "feat: add grain-profile-card molecule" 110 + ``` 111 + 112 + --- 113 + 114 + ## Task 2: Add getFollowers API Method 115 + 116 + **Files:** 117 + - Modify: `src/services/grain-api.js` 118 + 119 + **Step 1: Add getFollowers method** 120 + 121 + Add this method to the `GrainApiService` class after `#getFollowerCount`: 122 + 123 + ```javascript 124 + async getFollowers(handle, { first = 20, after = null } = {}) { 125 + // First get the user's DID 126 + const profileQuery = ` 127 + query GetDid($handle: String!) { 128 + socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) { 129 + edges { 130 + node { did } 131 + } 132 + } 133 + } 134 + `; 135 + const profileResponse = await this.#execute(profileQuery, { handle }); 136 + const did = profileResponse.data?.socialGrainActorProfile?.edges?.[0]?.node?.did; 137 + 138 + if (!did) { 139 + return { profiles: [], pageInfo: { hasNextPage: false, endCursor: null }, totalCount: 0 }; 140 + } 141 + 142 + // Query followers (people who follow this user) 143 + const query = ` 144 + query GetFollowers($did: String!, $first: Int, $after: String) { 145 + socialGrainGraphFollow( 146 + first: $first 147 + after: $after 148 + where: { subject: { eq: $did } } 149 + sortBy: [{ field: createdAt, direction: DESC }] 150 + ) { 151 + edges { 152 + node { 153 + appBskyActorProfileByDid { 154 + actorHandle 155 + displayName 156 + description 157 + avatar { url } 158 + } 159 + } 160 + } 161 + pageInfo { 162 + hasNextPage 163 + endCursor 164 + } 165 + totalCount 166 + } 167 + } 168 + `; 169 + 170 + const response = await this.#execute(query, { did, first, after }); 171 + const connection = response.data?.socialGrainGraphFollow; 172 + 173 + const profiles = connection?.edges 174 + ?.map(edge => edge.node.appBskyActorProfileByDid) 175 + ?.filter(Boolean) 176 + ?.map(profile => ({ 177 + handle: profile.actorHandle, 178 + displayName: profile.displayName || '', 179 + description: profile.description || '', 180 + avatarUrl: profile.avatar?.url || '' 181 + })) || []; 182 + 183 + return { 184 + profiles, 185 + pageInfo: connection?.pageInfo || { hasNextPage: false, endCursor: null }, 186 + totalCount: connection?.totalCount || 0 187 + }; 188 + } 189 + ``` 190 + 191 + **Step 2: Test the method** 192 + 193 + Temporarily add to browser console or test file: 194 + ```javascript 195 + import { grainApi } from './services/grain-api.js'; 196 + grainApi.getFollowers('gardenbehindthe.world').then(console.log); 197 + ``` 198 + 199 + **Step 3: Commit** 200 + 201 + ```bash 202 + git add src/services/grain-api.js 203 + git commit -m "feat: add getFollowers API method" 204 + ``` 205 + 206 + --- 207 + 208 + ## Task 3: Add getFollowing API Method 209 + 210 + **Files:** 211 + - Modify: `src/services/grain-api.js` 212 + 213 + **Step 1: Add getFollowing method** 214 + 215 + Add this method after `getFollowers`: 216 + 217 + ```javascript 218 + async getFollowing(handle, { first = 20, after = null } = {}) { 219 + // First get the user's DID 220 + const profileQuery = ` 221 + query GetDid($handle: String!) { 222 + socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) { 223 + edges { 224 + node { did } 225 + } 226 + } 227 + } 228 + `; 229 + const profileResponse = await this.#execute(profileQuery, { handle }); 230 + const did = profileResponse.data?.socialGrainActorProfile?.edges?.[0]?.node?.did; 231 + 232 + if (!did) { 233 + return { profiles: [], pageInfo: { hasNextPage: false, endCursor: null }, totalCount: 0 }; 234 + } 235 + 236 + // Query following (people this user follows) 237 + const followQuery = ` 238 + query GetFollowing($did: String!, $first: Int, $after: String) { 239 + socialGrainGraphFollow( 240 + first: $first 241 + after: $after 242 + where: { did: { eq: $did } } 243 + sortBy: [{ field: createdAt, direction: DESC }] 244 + ) { 245 + edges { 246 + node { 247 + subject 248 + } 249 + } 250 + pageInfo { 251 + hasNextPage 252 + endCursor 253 + } 254 + totalCount 255 + } 256 + } 257 + `; 258 + 259 + const followResponse = await this.#execute(followQuery, { did, first, after }); 260 + const connection = followResponse.data?.socialGrainGraphFollow; 261 + const subjectDids = connection?.edges?.map(e => e.node.subject).filter(Boolean) || []; 262 + 263 + if (subjectDids.length === 0) { 264 + return { 265 + profiles: [], 266 + pageInfo: connection?.pageInfo || { hasNextPage: false, endCursor: null }, 267 + totalCount: connection?.totalCount || 0 268 + }; 269 + } 270 + 271 + // Fetch profiles for the subject DIDs 272 + const profilesQuery = ` 273 + query GetProfiles($dids: [String!]!) { 274 + appBskyActorProfile(where: { did: { in: $dids } }) { 275 + edges { 276 + node { 277 + did 278 + actorHandle 279 + displayName 280 + description 281 + avatar { url } 282 + } 283 + } 284 + } 285 + } 286 + `; 287 + 288 + const profilesResponse = await this.#execute(profilesQuery, { dids: subjectDids }); 289 + const profilesMap = new Map(); 290 + profilesResponse.data?.appBskyActorProfile?.edges?.forEach(edge => { 291 + const node = edge.node; 292 + profilesMap.set(node.did, { 293 + handle: node.actorHandle, 294 + displayName: node.displayName || '', 295 + description: node.description || '', 296 + avatarUrl: node.avatar?.url || '' 297 + }); 298 + }); 299 + 300 + // Return profiles in order, with fallback for missing profiles 301 + const profiles = subjectDids.map(did => 302 + profilesMap.get(did) || { handle: did, displayName: '', description: '', avatarUrl: '' } 303 + ); 304 + 305 + return { 306 + profiles, 307 + pageInfo: connection?.pageInfo || { hasNextPage: false, endCursor: null }, 308 + totalCount: connection?.totalCount || 0 309 + }; 310 + } 311 + ``` 312 + 313 + **Step 2: Test the method** 314 + 315 + ```javascript 316 + grainApi.getFollowing('gardenbehindthe.world').then(console.log); 317 + ``` 318 + 319 + **Step 3: Commit** 320 + 321 + ```bash 322 + git add src/services/grain-api.js 323 + git commit -m "feat: add getFollowing API method" 324 + ``` 325 + 326 + --- 327 + 328 + ## Task 4: Create grain-profile-followers Page 329 + 330 + **Files:** 331 + - Create: `src/components/pages/grain-profile-followers.js` 332 + 333 + **Step 1: Create the page component** 334 + 335 + ```javascript 336 + import { LitElement, html, css } from 'lit'; 337 + import { router } from '../../router.js'; 338 + import { grainApi } from '../../services/grain-api.js'; 339 + import '../templates/grain-feed-layout.js'; 340 + import '../molecules/grain-profile-card.js'; 341 + import '../atoms/grain-spinner.js'; 342 + import '../atoms/grain-icon.js'; 343 + 344 + export class GrainProfileFollowers extends LitElement { 345 + static properties = { 346 + handle: { type: String }, 347 + _profiles: { state: true }, 348 + _loading: { state: true }, 349 + _hasMore: { state: true }, 350 + _cursor: { state: true }, 351 + _error: { state: true }, 352 + _totalCount: { state: true } 353 + }; 354 + 355 + static styles = css` 356 + :host { 357 + display: block; 358 + } 359 + .header-bar { 360 + display: flex; 361 + align-items: center; 362 + padding: var(--space-sm); 363 + border-bottom: 1px solid var(--color-border); 364 + } 365 + .back-button { 366 + display: flex; 367 + align-items: center; 368 + justify-content: center; 369 + background: none; 370 + border: none; 371 + padding: var(--space-sm); 372 + margin-left: calc(-1 * var(--space-sm)); 373 + cursor: pointer; 374 + color: var(--color-text-primary); 375 + } 376 + .title { 377 + font-size: var(--font-size-md); 378 + font-weight: var(--font-weight-semibold); 379 + margin-left: var(--space-sm); 380 + } 381 + .error { 382 + padding: var(--space-lg); 383 + text-align: center; 384 + color: var(--color-error); 385 + } 386 + .empty { 387 + padding: var(--space-xl); 388 + text-align: center; 389 + color: var(--color-text-secondary); 390 + } 391 + #sentinel { 392 + height: 1px; 393 + } 394 + `; 395 + 396 + #observer = null; 397 + 398 + constructor() { 399 + super(); 400 + this._profiles = []; 401 + this._loading = true; 402 + this._hasMore = true; 403 + this._cursor = null; 404 + this._error = null; 405 + this._totalCount = 0; 406 + } 407 + 408 + connectedCallback() { 409 + super.connectedCallback(); 410 + this.#loadInitial(); 411 + } 412 + 413 + disconnectedCallback() { 414 + super.disconnectedCallback(); 415 + this.#observer?.disconnect(); 416 + } 417 + 418 + firstUpdated() { 419 + this.#setupInfiniteScroll(); 420 + } 421 + 422 + updated(changedProperties) { 423 + if (changedProperties.has('handle') && this.handle) { 424 + this._profiles = []; 425 + this._cursor = null; 426 + this._hasMore = true; 427 + this.#loadInitial(); 428 + } 429 + } 430 + 431 + async #loadInitial() { 432 + if (!this.handle) return; 433 + 434 + try { 435 + this._loading = true; 436 + this._error = null; 437 + const result = await grainApi.getFollowers(this.handle, { first: 20 }); 438 + 439 + this._profiles = result.profiles; 440 + this._hasMore = result.pageInfo.hasNextPage; 441 + this._cursor = result.pageInfo.endCursor; 442 + this._totalCount = result.totalCount; 443 + } catch (err) { 444 + this._error = err.message; 445 + } finally { 446 + this._loading = false; 447 + } 448 + } 449 + 450 + async #loadMore() { 451 + if (this._loading || !this._hasMore) return; 452 + 453 + try { 454 + this._loading = true; 455 + const result = await grainApi.getFollowers(this.handle, { 456 + first: 20, 457 + after: this._cursor 458 + }); 459 + 460 + this._profiles = [...this._profiles, ...result.profiles]; 461 + this._hasMore = result.pageInfo.hasNextPage; 462 + this._cursor = result.pageInfo.endCursor; 463 + } catch (err) { 464 + this._error = err.message; 465 + } finally { 466 + this._loading = false; 467 + } 468 + } 469 + 470 + #setupInfiniteScroll() { 471 + const sentinel = this.shadowRoot.getElementById('sentinel'); 472 + if (!sentinel) return; 473 + 474 + this.#observer = new IntersectionObserver( 475 + (entries) => { 476 + if (entries[0].isIntersecting) { 477 + this.#loadMore(); 478 + } 479 + }, 480 + { rootMargin: '200px' } 481 + ); 482 + 483 + this.#observer.observe(sentinel); 484 + } 485 + 486 + #handleBack() { 487 + router.push(`/profile/${this.handle}`); 488 + } 489 + 490 + render() { 491 + return html` 492 + <grain-feed-layout> 493 + <div class="header-bar"> 494 + <button class="back-button" @click=${this.#handleBack}> 495 + <grain-icon name="back" size="20"></grain-icon> 496 + </button> 497 + <span class="title">Followers${this._totalCount ? ` (${this._totalCount})` : ''}</span> 498 + </div> 499 + 500 + ${this._error ? html` 501 + <p class="error">${this._error}</p> 502 + ` : ''} 503 + 504 + ${this._profiles.map(profile => html` 505 + <grain-profile-card 506 + handle=${profile.handle} 507 + displayName=${profile.displayName} 508 + description=${profile.description} 509 + avatarUrl=${profile.avatarUrl} 510 + ></grain-profile-card> 511 + `)} 512 + 513 + ${!this._loading && !this._profiles.length && !this._error ? html` 514 + <p class="empty">No followers yet</p> 515 + ` : ''} 516 + 517 + <div id="sentinel"></div> 518 + 519 + ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 520 + </grain-feed-layout> 521 + `; 522 + } 523 + } 524 + 525 + customElements.define('grain-profile-followers', GrainProfileFollowers); 526 + ``` 527 + 528 + **Step 2: Commit** 529 + 530 + ```bash 531 + git add src/components/pages/grain-profile-followers.js 532 + git commit -m "feat: add grain-profile-followers page" 533 + ``` 534 + 535 + --- 536 + 537 + ## Task 5: Create grain-profile-following Page 538 + 539 + **Files:** 540 + - Create: `src/components/pages/grain-profile-following.js` 541 + 542 + **Step 1: Create the page component** 543 + 544 + ```javascript 545 + import { LitElement, html, css } from 'lit'; 546 + import { router } from '../../router.js'; 547 + import { grainApi } from '../../services/grain-api.js'; 548 + import '../templates/grain-feed-layout.js'; 549 + import '../molecules/grain-profile-card.js'; 550 + import '../atoms/grain-spinner.js'; 551 + import '../atoms/grain-icon.js'; 552 + 553 + export class GrainProfileFollowing extends LitElement { 554 + static properties = { 555 + handle: { type: String }, 556 + _profiles: { state: true }, 557 + _loading: { state: true }, 558 + _hasMore: { state: true }, 559 + _cursor: { state: true }, 560 + _error: { state: true }, 561 + _totalCount: { state: true } 562 + }; 563 + 564 + static styles = css` 565 + :host { 566 + display: block; 567 + } 568 + .header-bar { 569 + display: flex; 570 + align-items: center; 571 + padding: var(--space-sm); 572 + border-bottom: 1px solid var(--color-border); 573 + } 574 + .back-button { 575 + display: flex; 576 + align-items: center; 577 + justify-content: center; 578 + background: none; 579 + border: none; 580 + padding: var(--space-sm); 581 + margin-left: calc(-1 * var(--space-sm)); 582 + cursor: pointer; 583 + color: var(--color-text-primary); 584 + } 585 + .title { 586 + font-size: var(--font-size-md); 587 + font-weight: var(--font-weight-semibold); 588 + margin-left: var(--space-sm); 589 + } 590 + .error { 591 + padding: var(--space-lg); 592 + text-align: center; 593 + color: var(--color-error); 594 + } 595 + .empty { 596 + padding: var(--space-xl); 597 + text-align: center; 598 + color: var(--color-text-secondary); 599 + } 600 + #sentinel { 601 + height: 1px; 602 + } 603 + `; 604 + 605 + #observer = null; 606 + 607 + constructor() { 608 + super(); 609 + this._profiles = []; 610 + this._loading = true; 611 + this._hasMore = true; 612 + this._cursor = null; 613 + this._error = null; 614 + this._totalCount = 0; 615 + } 616 + 617 + connectedCallback() { 618 + super.connectedCallback(); 619 + this.#loadInitial(); 620 + } 621 + 622 + disconnectedCallback() { 623 + super.disconnectedCallback(); 624 + this.#observer?.disconnect(); 625 + } 626 + 627 + firstUpdated() { 628 + this.#setupInfiniteScroll(); 629 + } 630 + 631 + updated(changedProperties) { 632 + if (changedProperties.has('handle') && this.handle) { 633 + this._profiles = []; 634 + this._cursor = null; 635 + this._hasMore = true; 636 + this.#loadInitial(); 637 + } 638 + } 639 + 640 + async #loadInitial() { 641 + if (!this.handle) return; 642 + 643 + try { 644 + this._loading = true; 645 + this._error = null; 646 + const result = await grainApi.getFollowing(this.handle, { first: 20 }); 647 + 648 + this._profiles = result.profiles; 649 + this._hasMore = result.pageInfo.hasNextPage; 650 + this._cursor = result.pageInfo.endCursor; 651 + this._totalCount = result.totalCount; 652 + } catch (err) { 653 + this._error = err.message; 654 + } finally { 655 + this._loading = false; 656 + } 657 + } 658 + 659 + async #loadMore() { 660 + if (this._loading || !this._hasMore) return; 661 + 662 + try { 663 + this._loading = true; 664 + const result = await grainApi.getFollowing(this.handle, { 665 + first: 20, 666 + after: this._cursor 667 + }); 668 + 669 + this._profiles = [...this._profiles, ...result.profiles]; 670 + this._hasMore = result.pageInfo.hasNextPage; 671 + this._cursor = result.pageInfo.endCursor; 672 + } catch (err) { 673 + this._error = err.message; 674 + } finally { 675 + this._loading = false; 676 + } 677 + } 678 + 679 + #setupInfiniteScroll() { 680 + const sentinel = this.shadowRoot.getElementById('sentinel'); 681 + if (!sentinel) return; 682 + 683 + this.#observer = new IntersectionObserver( 684 + (entries) => { 685 + if (entries[0].isIntersecting) { 686 + this.#loadMore(); 687 + } 688 + }, 689 + { rootMargin: '200px' } 690 + ); 691 + 692 + this.#observer.observe(sentinel); 693 + } 694 + 695 + #handleBack() { 696 + router.push(`/profile/${this.handle}`); 697 + } 698 + 699 + render() { 700 + return html` 701 + <grain-feed-layout> 702 + <div class="header-bar"> 703 + <button class="back-button" @click=${this.#handleBack}> 704 + <grain-icon name="back" size="20"></grain-icon> 705 + </button> 706 + <span class="title">Following${this._totalCount ? ` (${this._totalCount})` : ''}</span> 707 + </div> 708 + 709 + ${this._error ? html` 710 + <p class="error">${this._error}</p> 711 + ` : ''} 712 + 713 + ${this._profiles.map(profile => html` 714 + <grain-profile-card 715 + handle=${profile.handle} 716 + displayName=${profile.displayName} 717 + description=${profile.description} 718 + avatarUrl=${profile.avatarUrl} 719 + ></grain-profile-card> 720 + `)} 721 + 722 + ${!this._loading && !this._profiles.length && !this._error ? html` 723 + <p class="empty">Not following anyone yet</p> 724 + ` : ''} 725 + 726 + <div id="sentinel"></div> 727 + 728 + ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 729 + </grain-feed-layout> 730 + `; 731 + } 732 + } 733 + 734 + customElements.define('grain-profile-following', GrainProfileFollowing); 735 + ``` 736 + 737 + **Step 2: Commit** 738 + 739 + ```bash 740 + git add src/components/pages/grain-profile-following.js 741 + git commit -m "feat: add grain-profile-following page" 742 + ``` 743 + 744 + --- 745 + 746 + ## Task 6: Register Routes 747 + 748 + **Files:** 749 + - Modify: `src/components/pages/grain-app.js` 750 + 751 + **Step 1: Add imports** 752 + 753 + Add these imports after the existing page imports (around line 9): 754 + 755 + ```javascript 756 + import './grain-profile-followers.js'; 757 + import './grain-profile-following.js'; 758 + ``` 759 + 760 + **Step 2: Register routes** 761 + 762 + Add these routes BEFORE the `/profile/:handle` route (around line 33): 763 + 764 + ```javascript 765 + .register('/profile/:handle/followers', 'grain-profile-followers') 766 + .register('/profile/:handle/following', 'grain-profile-following') 767 + ``` 768 + 769 + The route section should look like: 770 + 771 + ```javascript 772 + router 773 + .register('/', 'grain-timeline') 774 + .register('/profile/:handle/followers', 'grain-profile-followers') 775 + .register('/profile/:handle/following', 'grain-profile-following') 776 + .register('/profile/:handle', 'grain-profile') 777 + .register('/profile/:handle/gallery/:rkey', 'grain-gallery-detail') 778 + .register('/settings', 'grain-settings') 779 + .register('/create', 'grain-create-gallery') 780 + .register('*', 'grain-timeline') 781 + .connect(outlet); 782 + ``` 783 + 784 + **Step 3: Verify routes work** 785 + 786 + Run: `npm run dev` 787 + Navigate to `/profile/gardenbehindthe.world/followers` and `/profile/gardenbehindthe.world/following` 788 + 789 + **Step 4: Commit** 790 + 791 + ```bash 792 + git add src/components/pages/grain-app.js 793 + git commit -m "feat: register followers/following routes" 794 + ``` 795 + 796 + --- 797 + 798 + ## Task 7: Make Profile Stats Clickable 799 + 800 + **Files:** 801 + - Modify: `src/components/molecules/grain-profile-stats.js` 802 + 803 + **Step 1: Update the component** 804 + 805 + Replace the entire file contents: 806 + 807 + ```javascript 808 + import { LitElement, html, css } from 'lit'; 809 + import { router } from '../../router.js'; 810 + 811 + export class GrainProfileStats extends LitElement { 812 + static properties = { 813 + handle: { type: String }, 814 + galleryCount: { type: Number }, 815 + followerCount: { type: Number }, 816 + followingCount: { type: Number } 817 + }; 818 + 819 + static styles = css` 820 + :host { 821 + display: flex; 822 + gap: var(--space-sm); 823 + font-size: var(--font-size-sm); 824 + } 825 + .stat { 826 + display: inline; 827 + } 828 + .stat-link { 829 + display: inline; 830 + background: none; 831 + border: none; 832 + padding: 0; 833 + font: inherit; 834 + cursor: pointer; 835 + color: inherit; 836 + } 837 + .stat-link:hover .label { 838 + text-decoration: underline; 839 + } 840 + .count { 841 + font-weight: var(--font-weight-bold); 842 + color: var(--color-text-primary); 843 + } 844 + .label { 845 + color: var(--color-text-secondary); 846 + } 847 + `; 848 + 849 + constructor() { 850 + super(); 851 + this.handle = ''; 852 + this.galleryCount = 0; 853 + this.followerCount = 0; 854 + this.followingCount = 0; 855 + } 856 + 857 + #formatCount(n) { 858 + if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`; 859 + if (n >= 1000) return `${(n / 1000).toFixed(1)}K`; 860 + return n.toString(); 861 + } 862 + 863 + #handleFollowersClick() { 864 + if (this.handle) { 865 + router.push(`/profile/${this.handle}/followers`); 866 + } 867 + } 868 + 869 + #handleFollowingClick() { 870 + if (this.handle) { 871 + router.push(`/profile/${this.handle}/following`); 872 + } 873 + } 874 + 875 + render() { 876 + return html` 877 + <div class="stat"> 878 + <span class="count">${this.#formatCount(this.galleryCount)}</span> 879 + <span class="label">galleries</span> 880 + </div> 881 + <button class="stat-link" @click=${this.#handleFollowersClick}> 882 + <span class="count">${this.#formatCount(this.followerCount)}</span> 883 + <span class="label">followers</span> 884 + </button> 885 + <button class="stat-link" @click=${this.#handleFollowingClick}> 886 + <span class="count">${this.#formatCount(this.followingCount)}</span> 887 + <span class="label">following</span> 888 + </button> 889 + `; 890 + } 891 + } 892 + 893 + customElements.define('grain-profile-stats', GrainProfileStats); 894 + ``` 895 + 896 + **Step 2: Commit** 897 + 898 + ```bash 899 + git add src/components/molecules/grain-profile-stats.js 900 + git commit -m "feat: make follower/following stats clickable" 901 + ``` 902 + 903 + --- 904 + 905 + ## Task 8: Pass Handle to Profile Stats 906 + 907 + **Files:** 908 + - Modify: `src/components/organisms/grain-profile-header.js` 909 + 910 + **Step 1: Find and update the grain-profile-stats usage** 911 + 912 + Find where `grain-profile-stats` is used and add the `handle` attribute: 913 + 914 + ```javascript 915 + <grain-profile-stats 916 + handle=${this.handle} 917 + galleryCount=${this.galleryCount} 918 + followerCount=${this.followerCount} 919 + followingCount=${this.followingCount} 920 + ></grain-profile-stats> 921 + ``` 922 + 923 + **Step 2: Verify the feature works end-to-end** 924 + 925 + Run: `npm run dev` 926 + 1. Navigate to a profile page 927 + 2. Click on "followers" count - should navigate to followers page 928 + 3. Click on "following" count - should navigate to following page 929 + 4. Click back arrow - should return to profile 930 + 931 + **Step 3: Commit** 932 + 933 + ```bash 934 + git add src/components/organisms/grain-profile-header.js 935 + git commit -m "feat: pass handle to profile stats for navigation" 936 + ``` 937 + 938 + --- 939 + 940 + ## Summary 941 + 942 + | Task | Description | Files | 943 + |------|-------------|-------| 944 + | 1 | Create grain-profile-card molecule | Create: `grain-profile-card.js` | 945 + | 2 | Add getFollowers API method | Modify: `grain-api.js` | 946 + | 3 | Add getFollowing API method | Modify: `grain-api.js` | 947 + | 4 | Create followers page | Create: `grain-profile-followers.js` | 948 + | 5 | Create following page | Create: `grain-profile-following.js` | 949 + | 6 | Register routes | Modify: `grain-app.js` | 950 + | 7 | Make stats clickable | Modify: `grain-profile-stats.js` | 951 + | 8 | Pass handle to stats | Modify: `grain-profile-header.js` |