+1033
docs/plans/2025-12-28-comments-feature.md
+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)