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