docs: add comments feature implementation plan

Changed files
+1033
docs
+1033
docs/plans/2025-12-28-comments-feature.md
··· 1 + # Comments Feature Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add Instagram-style bottom sheet for viewing and posting comments on galleries. 6 + 7 + **Architecture:** Bottom sheet component opens when comment icon is tapped, shows paginated comment list with threaded replies (single indent level), and a fixed input bar at bottom with user avatar and send button. Login required to view comments. 8 + 9 + **Tech Stack:** Lit 3, Web Components, GraphQL (quickslice), CSS custom properties 10 + 11 + --- 12 + 13 + ## Task 1: Add createComment to mutations service 14 + 15 + **Files:** 16 + - Modify: `src/services/mutations.js` 17 + 18 + **Step 1: Add createComment method** 19 + 20 + Add after the `toggleFollow` method in `src/services/mutations.js`: 21 + 22 + ```javascript 23 + async createComment(galleryUri, text, replyToUri = null) { 24 + const client = auth.getClient(); 25 + const input = { 26 + subject: galleryUri, 27 + text, 28 + createdAt: new Date().toISOString() 29 + }; 30 + 31 + if (replyToUri) { 32 + input.replyTo = replyToUri; 33 + } 34 + 35 + const result = await client.mutate(` 36 + mutation CreateComment($input: SocialGrainCommentInput!) { 37 + createSocialGrainComment(input: $input) { uri } 38 + } 39 + `, { input }); 40 + 41 + return result.createSocialGrainComment.uri; 42 + } 43 + ``` 44 + 45 + **Step 2: Verify the file saves correctly** 46 + 47 + Run: `head -20 src/services/mutations.js` 48 + 49 + **Step 3: Commit** 50 + 51 + ```bash 52 + git add src/services/mutations.js 53 + git commit -m "feat: add createComment mutation" 54 + ``` 55 + 56 + --- 57 + 58 + ## Task 2: Add getComments method to grain-api service 59 + 60 + **Files:** 61 + - Modify: `src/services/grain-api.js` 62 + 63 + **Step 1: Add getComments method** 64 + 65 + Add before the closing brace of the class in `src/services/grain-api.js`: 66 + 67 + ```javascript 68 + async getComments(galleryUri, { first = 20, after = null } = {}) { 69 + const query = ` 70 + query GetComments($galleryUri: String!, $first: Int, $after: String) { 71 + socialGrainComment( 72 + first: $first 73 + after: $after 74 + where: { subject: { eq: $galleryUri } } 75 + sortBy: [{ field: createdAt, direction: ASC }] 76 + ) { 77 + edges { 78 + node { 79 + uri 80 + text 81 + createdAt 82 + actorHandle 83 + replyTo 84 + socialGrainActorProfileByDid { 85 + displayName 86 + avatar { url(preset: "avatar") } 87 + } 88 + } 89 + } 90 + pageInfo { 91 + hasNextPage 92 + endCursor 93 + } 94 + totalCount 95 + } 96 + } 97 + `; 98 + 99 + const response = await this.#execute(query, { galleryUri, first, after }); 100 + const connection = response.data?.socialGrainComment; 101 + 102 + if (!connection) { 103 + return { comments: [], pageInfo: { hasNextPage: false, endCursor: null }, totalCount: 0 }; 104 + } 105 + 106 + const comments = connection.edges.map(edge => { 107 + const node = edge.node; 108 + const profile = node.socialGrainActorProfileByDid; 109 + return { 110 + uri: node.uri, 111 + text: node.text, 112 + createdAt: node.createdAt, 113 + handle: node.actorHandle, 114 + displayName: profile?.displayName || '', 115 + avatarUrl: profile?.avatar?.url || '', 116 + replyToUri: node.replyTo || null 117 + }; 118 + }); 119 + 120 + return { 121 + comments, 122 + pageInfo: connection.pageInfo || { hasNextPage: false, endCursor: null }, 123 + totalCount: connection.totalCount || 0 124 + }; 125 + } 126 + ``` 127 + 128 + **Step 2: Commit** 129 + 130 + ```bash 131 + git add src/services/grain-api.js 132 + git commit -m "feat: add getComments query to grain-api" 133 + ``` 134 + 135 + --- 136 + 137 + ## Task 3: Create grain-comment-input component 138 + 139 + **Files:** 140 + - Create: `src/components/molecules/grain-comment-input.js` 141 + 142 + **Step 1: Create the component file** 143 + 144 + ```javascript 145 + import { LitElement, html, css } from 'lit'; 146 + import '../atoms/grain-avatar.js'; 147 + import '../atoms/grain-spinner.js'; 148 + 149 + export class GrainCommentInput extends LitElement { 150 + static properties = { 151 + avatarUrl: { type: String }, 152 + value: { type: String }, 153 + placeholder: { type: String }, 154 + disabled: { type: Boolean }, 155 + loading: { type: Boolean } 156 + }; 157 + 158 + static styles = css` 159 + :host { 160 + display: flex; 161 + align-items: center; 162 + gap: var(--space-sm); 163 + padding: var(--space-sm); 164 + border-top: 1px solid var(--color-border); 165 + background: var(--color-bg-primary); 166 + } 167 + .input-wrapper { 168 + flex: 1; 169 + display: flex; 170 + align-items: center; 171 + gap: var(--space-sm); 172 + background: var(--color-bg-secondary); 173 + border-radius: 20px; 174 + padding: var(--space-xs) var(--space-sm); 175 + } 176 + input { 177 + flex: 1; 178 + background: none; 179 + border: none; 180 + outline: none; 181 + font-size: var(--font-size-sm); 182 + color: var(--color-text-primary); 183 + font-family: inherit; 184 + } 185 + input::placeholder { 186 + color: var(--color-text-secondary); 187 + } 188 + input:disabled { 189 + opacity: 0.5; 190 + } 191 + .send-button { 192 + display: flex; 193 + align-items: center; 194 + justify-content: center; 195 + background: none; 196 + border: none; 197 + padding: var(--space-xs); 198 + cursor: pointer; 199 + color: var(--color-accent); 200 + font-size: var(--font-size-sm); 201 + font-weight: var(--font-weight-semibold); 202 + } 203 + .send-button:disabled { 204 + opacity: 0.5; 205 + cursor: not-allowed; 206 + } 207 + grain-spinner { 208 + --spinner-size: 16px; 209 + } 210 + `; 211 + 212 + constructor() { 213 + super(); 214 + this.avatarUrl = ''; 215 + this.value = ''; 216 + this.placeholder = 'Add a comment...'; 217 + this.disabled = false; 218 + this.loading = false; 219 + } 220 + 221 + #handleInput(e) { 222 + this.value = e.target.value; 223 + this.dispatchEvent(new CustomEvent('input-change', { 224 + detail: { value: this.value } 225 + })); 226 + } 227 + 228 + #handleSend() { 229 + if (!this.value.trim() || this.disabled || this.loading) return; 230 + this.dispatchEvent(new CustomEvent('send', { 231 + detail: { value: this.value.trim() } 232 + })); 233 + } 234 + 235 + focus() { 236 + this.shadowRoot.querySelector('input')?.focus(); 237 + } 238 + 239 + clear() { 240 + this.value = ''; 241 + } 242 + 243 + render() { 244 + const canSend = this.value.trim() && !this.disabled && !this.loading; 245 + 246 + return html` 247 + <grain-avatar src=${this.avatarUrl} size="32"></grain-avatar> 248 + <div class="input-wrapper"> 249 + <input 250 + type="text" 251 + .value=${this.value} 252 + placeholder=${this.placeholder} 253 + ?disabled=${this.disabled || this.loading} 254 + @input=${this.#handleInput} 255 + /> 256 + <button 257 + class="send-button" 258 + type="button" 259 + ?disabled=${!canSend} 260 + @click=${this.#handleSend} 261 + > 262 + ${this.loading ? html`<grain-spinner></grain-spinner>` : 'Post'} 263 + </button> 264 + </div> 265 + `; 266 + } 267 + } 268 + 269 + customElements.define('grain-comment-input', GrainCommentInput); 270 + ``` 271 + 272 + **Step 2: Commit** 273 + 274 + ```bash 275 + git add src/components/molecules/grain-comment-input.js 276 + git commit -m "feat: add grain-comment-input component" 277 + ``` 278 + 279 + --- 280 + 281 + ## Task 4: Update grain-comment to support replies and tap handling 282 + 283 + **Files:** 284 + - Modify: `src/components/molecules/grain-comment.js` 285 + 286 + **Step 1: Update the component** 287 + 288 + Replace the entire file content: 289 + 290 + ```javascript 291 + import { LitElement, html, css } from 'lit'; 292 + import { router } from '../../router.js'; 293 + import '../atoms/grain-avatar.js'; 294 + 295 + export class GrainComment extends LitElement { 296 + static properties = { 297 + uri: { type: String }, 298 + handle: { type: String }, 299 + displayName: { type: String }, 300 + avatarUrl: { type: String }, 301 + text: { type: String }, 302 + createdAt: { type: String }, 303 + isReply: { type: Boolean } 304 + }; 305 + 306 + static styles = css` 307 + :host { 308 + display: block; 309 + padding: var(--space-xs) 0; 310 + } 311 + :host([is-reply]) { 312 + padding-left: 40px; 313 + } 314 + .comment { 315 + display: flex; 316 + gap: var(--space-sm); 317 + cursor: pointer; 318 + } 319 + .content { 320 + flex: 1; 321 + min-width: 0; 322 + } 323 + .text-line { 324 + font-size: var(--font-size-sm); 325 + color: var(--color-text-primary); 326 + line-height: 1.4; 327 + } 328 + .handle { 329 + font-weight: var(--font-weight-semibold); 330 + cursor: pointer; 331 + } 332 + .handle:hover { 333 + text-decoration: underline; 334 + } 335 + .text { 336 + margin-left: var(--space-xs); 337 + word-break: break-word; 338 + } 339 + .meta { 340 + display: flex; 341 + gap: var(--space-sm); 342 + margin-top: var(--space-xxs); 343 + } 344 + .time { 345 + font-size: var(--font-size-xs); 346 + color: var(--color-text-secondary); 347 + } 348 + .reply-btn { 349 + font-size: var(--font-size-xs); 350 + color: var(--color-text-secondary); 351 + background: none; 352 + border: none; 353 + padding: 0; 354 + cursor: pointer; 355 + font-family: inherit; 356 + font-weight: var(--font-weight-semibold); 357 + } 358 + .reply-btn:hover { 359 + color: var(--color-text-primary); 360 + } 361 + `; 362 + 363 + constructor() { 364 + super(); 365 + this.uri = ''; 366 + this.handle = ''; 367 + this.displayName = ''; 368 + this.avatarUrl = ''; 369 + this.text = ''; 370 + this.createdAt = ''; 371 + this.isReply = false; 372 + } 373 + 374 + #handleProfileClick(e) { 375 + e.stopPropagation(); 376 + router.push(`/profile/${this.handle}`); 377 + } 378 + 379 + #handleReplyClick(e) { 380 + e.stopPropagation(); 381 + this.dispatchEvent(new CustomEvent('reply', { 382 + detail: { uri: this.uri, handle: this.handle }, 383 + bubbles: true, 384 + composed: true 385 + })); 386 + } 387 + 388 + #formatTime(iso) { 389 + const date = new Date(iso); 390 + const now = new Date(); 391 + const diffMs = now - date; 392 + const diffMins = Math.floor(diffMs / 60000); 393 + const diffHours = Math.floor(diffMs / 3600000); 394 + const diffDays = Math.floor(diffMs / 86400000); 395 + 396 + if (diffMins < 1) return 'now'; 397 + if (diffMins < 60) return `${diffMins}m`; 398 + if (diffHours < 24) return `${diffHours}h`; 399 + if (diffDays < 7) return `${diffDays}d`; 400 + return `${Math.floor(diffDays / 7)}w`; 401 + } 402 + 403 + render() { 404 + return html` 405 + <div class="comment"> 406 + <grain-avatar 407 + src=${this.avatarUrl} 408 + size="28" 409 + @click=${this.#handleProfileClick} 410 + ></grain-avatar> 411 + <div class="content"> 412 + <div class="text-line"> 413 + <span class="handle" @click=${this.#handleProfileClick}> 414 + ${this.handle} 415 + </span> 416 + <span class="text">${this.text}</span> 417 + </div> 418 + <div class="meta"> 419 + <span class="time">${this.#formatTime(this.createdAt)}</span> 420 + <button class="reply-btn" @click=${this.#handleReplyClick}>Reply</button> 421 + </div> 422 + </div> 423 + </div> 424 + `; 425 + } 426 + } 427 + 428 + customElements.define('grain-comment', GrainComment); 429 + ``` 430 + 431 + **Step 2: Commit** 432 + 433 + ```bash 434 + git add src/components/molecules/grain-comment.js 435 + git commit -m "feat: update grain-comment with avatar, reply button, and time" 436 + ``` 437 + 438 + --- 439 + 440 + ## Task 5: Create grain-comment-sheet component 441 + 442 + **Files:** 443 + - Create: `src/components/organisms/grain-comment-sheet.js` 444 + 445 + **Step 1: Create the component file** 446 + 447 + ```javascript 448 + import { LitElement, html, css } from 'lit'; 449 + import { grainApi } from '../../services/grain-api.js'; 450 + import { mutations } from '../../services/mutations.js'; 451 + import { auth } from '../../services/auth.js'; 452 + import { recordCache } from '../../services/record-cache.js'; 453 + import '../molecules/grain-comment.js'; 454 + import '../molecules/grain-comment-input.js'; 455 + import '../atoms/grain-spinner.js'; 456 + import '../atoms/grain-icon.js'; 457 + 458 + export class GrainCommentSheet extends LitElement { 459 + static properties = { 460 + open: { type: Boolean, reflect: true }, 461 + galleryUri: { type: String }, 462 + _comments: { state: true }, 463 + _loading: { state: true }, 464 + _loadingMore: { state: true }, 465 + _posting: { state: true }, 466 + _inputValue: { state: true }, 467 + _replyToUri: { state: true }, 468 + _replyToHandle: { state: true }, 469 + _pageInfo: { state: true }, 470 + _totalCount: { state: true } 471 + }; 472 + 473 + static styles = css` 474 + :host { 475 + display: none; 476 + } 477 + :host([open]) { 478 + display: block; 479 + } 480 + .overlay { 481 + position: fixed; 482 + inset: 0; 483 + background: rgba(0, 0, 0, 0.5); 484 + z-index: 1000; 485 + } 486 + .sheet { 487 + position: fixed; 488 + bottom: 0; 489 + left: 0; 490 + right: 0; 491 + max-height: 70vh; 492 + background: var(--color-bg-primary); 493 + border-radius: 12px 12px 0 0; 494 + display: flex; 495 + flex-direction: column; 496 + z-index: 1001; 497 + animation: slideUp 0.2s ease-out; 498 + } 499 + @keyframes slideUp { 500 + from { transform: translateY(100%); } 501 + to { transform: translateY(0); } 502 + } 503 + .header { 504 + display: flex; 505 + align-items: center; 506 + justify-content: center; 507 + padding: var(--space-sm) var(--space-md); 508 + border-bottom: 1px solid var(--color-border); 509 + position: relative; 510 + } 511 + .header h2 { 512 + margin: 0; 513 + font-size: var(--font-size-md); 514 + font-weight: var(--font-weight-semibold); 515 + } 516 + .close-button { 517 + position: absolute; 518 + right: var(--space-sm); 519 + background: none; 520 + border: none; 521 + padding: var(--space-sm); 522 + cursor: pointer; 523 + color: var(--color-text-primary); 524 + } 525 + .comments-list { 526 + flex: 1; 527 + overflow-y: auto; 528 + padding: var(--space-sm) var(--space-md); 529 + -webkit-overflow-scrolling: touch; 530 + } 531 + .load-more { 532 + display: flex; 533 + justify-content: center; 534 + padding: var(--space-sm); 535 + } 536 + .load-more-btn { 537 + background: none; 538 + border: none; 539 + color: var(--color-text-secondary); 540 + font-size: var(--font-size-sm); 541 + cursor: pointer; 542 + padding: var(--space-xs) var(--space-sm); 543 + } 544 + .load-more-btn:hover { 545 + color: var(--color-text-primary); 546 + } 547 + .empty { 548 + text-align: center; 549 + padding: var(--space-xl); 550 + color: var(--color-text-secondary); 551 + font-size: var(--font-size-sm); 552 + } 553 + .loading { 554 + display: flex; 555 + justify-content: center; 556 + padding: var(--space-xl); 557 + } 558 + grain-comment-input { 559 + flex-shrink: 0; 560 + } 561 + `; 562 + 563 + constructor() { 564 + super(); 565 + this.open = false; 566 + this.galleryUri = ''; 567 + this._comments = []; 568 + this._loading = false; 569 + this._loadingMore = false; 570 + this._posting = false; 571 + this._inputValue = ''; 572 + this._replyToUri = null; 573 + this._replyToHandle = null; 574 + this._pageInfo = { hasNextPage: false, endCursor: null }; 575 + this._totalCount = 0; 576 + } 577 + 578 + updated(changedProps) { 579 + if (changedProps.has('open') && this.open && this.galleryUri) { 580 + this.#loadComments(); 581 + } 582 + } 583 + 584 + async #loadComments() { 585 + this._loading = true; 586 + this._comments = []; 587 + 588 + try { 589 + const result = await grainApi.getComments(this.galleryUri, { first: 20 }); 590 + this._comments = this.#organizeComments(result.comments); 591 + this._pageInfo = result.pageInfo; 592 + this._totalCount = result.totalCount; 593 + } catch (err) { 594 + console.error('Failed to load comments:', err); 595 + } finally { 596 + this._loading = false; 597 + } 598 + } 599 + 600 + async #loadMore() { 601 + if (this._loadingMore || !this._pageInfo.hasNextPage) return; 602 + 603 + this._loadingMore = true; 604 + try { 605 + const result = await grainApi.getComments(this.galleryUri, { 606 + first: 20, 607 + after: this._pageInfo.endCursor 608 + }); 609 + const newComments = this.#organizeComments(result.comments); 610 + this._comments = [...this._comments, ...newComments]; 611 + this._pageInfo = result.pageInfo; 612 + } catch (err) { 613 + console.error('Failed to load more comments:', err); 614 + } finally { 615 + this._loadingMore = false; 616 + } 617 + } 618 + 619 + #organizeComments(comments) { 620 + // Group replies under their parents 621 + const roots = []; 622 + const replyMap = new Map(); 623 + 624 + comments.forEach(comment => { 625 + if (comment.replyToUri) { 626 + const replies = replyMap.get(comment.replyToUri) || []; 627 + replies.push({ ...comment, isReply: true }); 628 + replyMap.set(comment.replyToUri, replies); 629 + } else { 630 + roots.push(comment); 631 + } 632 + }); 633 + 634 + // Flatten: root, then its replies 635 + const organized = []; 636 + roots.forEach(root => { 637 + organized.push(root); 638 + const replies = replyMap.get(root.uri) || []; 639 + replies.forEach(reply => organized.push(reply)); 640 + }); 641 + 642 + return organized; 643 + } 644 + 645 + #handleClose() { 646 + this.open = false; 647 + this._replyToUri = null; 648 + this._replyToHandle = null; 649 + this._inputValue = ''; 650 + this.dispatchEvent(new CustomEvent('close')); 651 + } 652 + 653 + #handleOverlayClick(e) { 654 + if (e.target === e.currentTarget) { 655 + this.#handleClose(); 656 + } 657 + } 658 + 659 + #handleInputChange(e) { 660 + this._inputValue = e.detail.value; 661 + } 662 + 663 + async #handleSend(e) { 664 + const text = e.detail.value; 665 + if (!text || this._posting) return; 666 + 667 + this._posting = true; 668 + try { 669 + const commentUri = await mutations.createComment( 670 + this.galleryUri, 671 + text, 672 + this._replyToUri 673 + ); 674 + 675 + // Add new comment to list 676 + const newComment = { 677 + uri: commentUri, 678 + text, 679 + createdAt: new Date().toISOString(), 680 + handle: auth.user?.handle || '', 681 + displayName: auth.user?.displayName || '', 682 + avatarUrl: auth.user?.avatarUrl || '', 683 + replyToUri: this._replyToUri, 684 + isReply: !!this._replyToUri 685 + }; 686 + 687 + if (this._replyToUri) { 688 + // Insert after parent 689 + const parentIndex = this._comments.findIndex(c => c.uri === this._replyToUri); 690 + if (parentIndex >= 0) { 691 + // Find last reply of this parent 692 + let insertIndex = parentIndex + 1; 693 + while (insertIndex < this._comments.length && this._comments[insertIndex].isReply) { 694 + insertIndex++; 695 + } 696 + this._comments = [ 697 + ...this._comments.slice(0, insertIndex), 698 + newComment, 699 + ...this._comments.slice(insertIndex) 700 + ]; 701 + } else { 702 + this._comments = [...this._comments, newComment]; 703 + } 704 + } else { 705 + this._comments = [...this._comments, newComment]; 706 + } 707 + 708 + this._totalCount++; 709 + 710 + // Update comment count in cache 711 + recordCache.set(this.galleryUri, { 712 + commentCount: this._totalCount 713 + }); 714 + 715 + // Clear input 716 + this._inputValue = ''; 717 + this._replyToUri = null; 718 + this._replyToHandle = null; 719 + this.shadowRoot.querySelector('grain-comment-input')?.clear(); 720 + } catch (err) { 721 + console.error('Failed to post comment:', err); 722 + } finally { 723 + this._posting = false; 724 + } 725 + } 726 + 727 + #handleReply(e) { 728 + const { uri, handle } = e.detail; 729 + this._replyToUri = uri; 730 + this._replyToHandle = handle; 731 + this._inputValue = `@${handle} `; 732 + 733 + // Scroll comment into view 734 + const commentEl = this.shadowRoot.querySelector(`grain-comment[uri="${uri}"]`); 735 + commentEl?.scrollIntoView({ behavior: 'smooth', block: 'start' }); 736 + 737 + // Focus input 738 + this.shadowRoot.querySelector('grain-comment-input')?.focus(); 739 + } 740 + 741 + render() { 742 + const userAvatarUrl = auth.user?.avatarUrl || ''; 743 + 744 + return html` 745 + <div class="overlay" @click=${this.#handleOverlayClick}> 746 + <div class="sheet"> 747 + <div class="header"> 748 + <h2>Comments</h2> 749 + <button class="close-button" @click=${this.#handleClose}> 750 + <grain-icon name="close" size="20"></grain-icon> 751 + </button> 752 + </div> 753 + 754 + <div class="comments-list"> 755 + ${this._loading ? html` 756 + <div class="loading"><grain-spinner></grain-spinner></div> 757 + ` : this._comments.length === 0 ? html` 758 + <div class="empty">No comments yet. Be the first!</div> 759 + ` : html` 760 + ${this._pageInfo.hasNextPage ? html` 761 + <div class="load-more"> 762 + ${this._loadingMore ? html` 763 + <grain-spinner></grain-spinner> 764 + ` : html` 765 + <button class="load-more-btn" @click=${this.#loadMore}> 766 + Load earlier comments 767 + </button> 768 + `} 769 + </div> 770 + ` : ''} 771 + ${this._comments.map(comment => html` 772 + <grain-comment 773 + uri=${comment.uri} 774 + handle=${comment.handle} 775 + displayName=${comment.displayName} 776 + avatarUrl=${comment.avatarUrl} 777 + text=${comment.text} 778 + createdAt=${comment.createdAt} 779 + ?is-reply=${comment.isReply} 780 + @reply=${this.#handleReply} 781 + ></grain-comment> 782 + `)} 783 + `} 784 + </div> 785 + 786 + <grain-comment-input 787 + avatarUrl=${userAvatarUrl} 788 + .value=${this._inputValue} 789 + ?loading=${this._posting} 790 + @input-change=${this.#handleInputChange} 791 + @send=${this.#handleSend} 792 + ></grain-comment-input> 793 + </div> 794 + </div> 795 + `; 796 + } 797 + } 798 + 799 + customElements.define('grain-comment-sheet', GrainCommentSheet); 800 + ``` 801 + 802 + **Step 2: Commit** 803 + 804 + ```bash 805 + git add src/components/organisms/grain-comment-sheet.js 806 + git commit -m "feat: add grain-comment-sheet bottom sheet component" 807 + ``` 808 + 809 + --- 810 + 811 + ## Task 6: Make comment icon interactive in engagement bar 812 + 813 + **Files:** 814 + - Modify: `src/components/organisms/grain-engagement-bar.js` 815 + 816 + **Step 1: Add galleryUri prop if not already present, and emit comment-click event** 817 + 818 + The component already has `galleryUri` prop. Add click handler to comment stat: 819 + 820 + Find this block: 821 + ```javascript 822 + <grain-stat-count 823 + icon="comment" 824 + count=${this.commentCount} 825 + ></grain-stat-count> 826 + ``` 827 + 828 + Replace with: 829 + ```javascript 830 + <grain-stat-count 831 + icon="comment" 832 + count=${this.commentCount} 833 + ?interactive=${true} 834 + @stat-click=${this.#handleCommentClick} 835 + ></grain-stat-count> 836 + ``` 837 + 838 + **Step 2: Add the handler method** 839 + 840 + Add after `#handleFavoriteClick`: 841 + 842 + ```javascript 843 + #handleCommentClick() { 844 + this.dispatchEvent(new CustomEvent('comment-click', { 845 + bubbles: true, 846 + composed: true 847 + })); 848 + } 849 + ``` 850 + 851 + **Step 3: Commit** 852 + 853 + ```bash 854 + git add src/components/organisms/grain-engagement-bar.js 855 + git commit -m "feat: make comment icon interactive in engagement bar" 856 + ``` 857 + 858 + --- 859 + 860 + ## Task 7: Integrate comment sheet into gallery detail page 861 + 862 + **Files:** 863 + - Modify: `src/components/pages/grain-gallery-detail.js` 864 + 865 + **Step 1: Add import** 866 + 867 + Add at top with other imports: 868 + ```javascript 869 + import '../organisms/grain-comment-sheet.js'; 870 + ``` 871 + 872 + **Step 2: Add state property** 873 + 874 + Add to `static properties`: 875 + ```javascript 876 + _commentSheetOpen: { state: true } 877 + ``` 878 + 879 + **Step 3: Initialize in constructor** 880 + 881 + Add to constructor: 882 + ```javascript 883 + this._commentSheetOpen = false; 884 + ``` 885 + 886 + **Step 4: Add handler methods** 887 + 888 + Add after `#handleBack`: 889 + 890 + ```javascript 891 + #handleCommentClick() { 892 + if (!auth.isAuthenticated) { 893 + this.#showLoginDialog(); 894 + return; 895 + } 896 + this._commentSheetOpen = true; 897 + } 898 + 899 + #handleCommentSheetClose() { 900 + this._commentSheetOpen = false; 901 + } 902 + 903 + #showLoginDialog() { 904 + // Dispatch event to show login at page level 905 + this.dispatchEvent(new CustomEvent('show-login', { 906 + bubbles: true, 907 + composed: true 908 + })); 909 + } 910 + ``` 911 + 912 + **Step 5: Add event listener to engagement bar** 913 + 914 + Find: 915 + ```javascript 916 + <grain-engagement-bar 917 + ``` 918 + 919 + Add the event handler: 920 + ```javascript 921 + <grain-engagement-bar 922 + ...existing props... 923 + @comment-click=${this.#handleCommentClick} 924 + ></grain-engagement-bar> 925 + ``` 926 + 927 + **Step 6: Add comment sheet to render** 928 + 929 + Add before the closing `</grain-feed-layout>`: 930 + 931 + ```javascript 932 + <grain-comment-sheet 933 + ?open=${this._commentSheetOpen} 934 + galleryUri=${this._gallery?.uri || ''} 935 + @close=${this.#handleCommentSheetClose} 936 + ></grain-comment-sheet> 937 + ``` 938 + 939 + **Step 7: Commit** 940 + 941 + ```bash 942 + git add src/components/pages/grain-gallery-detail.js 943 + git commit -m "feat: integrate comment sheet into gallery detail page" 944 + ``` 945 + 946 + --- 947 + 948 + ## Task 8: Add close icon to grain-icon if missing 949 + 950 + **Files:** 951 + - Modify: `src/components/atoms/grain-icon.js` 952 + 953 + **Step 1: Check if close icon exists** 954 + 955 + Read the file and check if 'close' is in the icons object. If not, add it. 956 + 957 + The close icon SVG path: 958 + ```javascript 959 + close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z' 960 + ``` 961 + 962 + **Step 2: Commit (if changes made)** 963 + 964 + ```bash 965 + git add src/components/atoms/grain-icon.js 966 + git commit -m "feat: add close icon to grain-icon" 967 + ``` 968 + 969 + --- 970 + 971 + ## Task 9: Remove old grain-comment-list from gallery detail 972 + 973 + **Files:** 974 + - Modify: `src/components/pages/grain-gallery-detail.js` 975 + 976 + **Step 1: Remove import** 977 + 978 + Remove this line: 979 + ```javascript 980 + import '../organisms/grain-comment-list.js'; 981 + ``` 982 + 983 + **Step 2: Remove usage** 984 + 985 + Remove this block from render: 986 + ```javascript 987 + <grain-comment-list 988 + .comments=${this._gallery.comments} 989 + totalCount=${this._gallery.commentCount} 990 + ></grain-comment-list> 991 + ``` 992 + 993 + **Step 3: Commit** 994 + 995 + ```bash 996 + git add src/components/pages/grain-gallery-detail.js 997 + git commit -m "refactor: remove inline comment list in favor of sheet" 998 + ``` 999 + 1000 + --- 1001 + 1002 + ## Task 10: Test the feature manually 1003 + 1004 + **Steps:** 1005 + 1. Run `npm run dev` 1006 + 2. Navigate to a gallery detail page 1007 + 3. Tap the comment icon 1008 + 4. Verify login dialog shows if not logged in 1009 + 5. Log in, tap comment icon again 1010 + 6. Verify bottom sheet opens with comments (or empty state) 1011 + 7. Type a comment and tap Post 1012 + 8. Verify comment appears in list 1013 + 9. Tap Reply on a comment 1014 + 10. Verify input populates with @handle and cursor is focused 1015 + 11. Post a reply 1016 + 12. Verify reply appears indented under parent 1017 + 13. Close sheet and verify comment count updated 1018 + 1019 + --- 1020 + 1021 + ## Summary 1022 + 1023 + **New files:** 1024 + - `src/components/molecules/grain-comment-input.js` 1025 + - `src/components/organisms/grain-comment-sheet.js` 1026 + 1027 + **Modified files:** 1028 + - `src/services/mutations.js` - added `createComment` 1029 + - `src/services/grain-api.js` - added `getComments` 1030 + - `src/components/molecules/grain-comment.js` - added avatar, reply, time 1031 + - `src/components/organisms/grain-engagement-bar.js` - made comment clickable 1032 + - `src/components/pages/grain-gallery-detail.js` - integrated sheet 1033 + - `src/components/atoms/grain-icon.js` - added close icon (if needed)