+708
docs/plans/2025-12-25-create-gallery.md
+708
docs/plans/2025-12-25-create-gallery.md
···
1
+
# Create Gallery Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Add a + button to bottom nav that opens a photo picker, then navigates to a create gallery page where users add title/description and publish.
6
+
7
+
**Architecture:** Photo picker first flow. Files selected in bottom nav are processed (resized under 900KB), stored in draft-gallery service, then create page reads them. Publishing uploads blobs, creates photo records, gallery record, and gallery items.
8
+
9
+
**Tech Stack:** Lit 3.x, quickslice-client-js for ATProto writes, Canvas API for image resizing
10
+
11
+
---
12
+
13
+
### Task 1: Add Plus Icon
14
+
15
+
**Files:**
16
+
- Modify: `src/components/atoms/grain-icon.js`
17
+
18
+
**Step 1: Add plus icon to ICONS object**
19
+
20
+
Add after `logout`:
21
+
22
+
```javascript
23
+
plus: 'fa-solid fa-plus'
24
+
```
25
+
26
+
**Step 2: Commit**
27
+
28
+
```bash
29
+
git add src/components/atoms/grain-icon.js
30
+
git commit -m "feat: add plus icon"
31
+
```
32
+
33
+
---
34
+
35
+
### Task 2: Create Image Resize Utility
36
+
37
+
**Files:**
38
+
- Create: `src/utils/image-resize.js`
39
+
40
+
**Step 1: Create the image resize utility**
41
+
42
+
```javascript
43
+
export function readFileAsDataURL(file) {
44
+
return new Promise((resolve, reject) => {
45
+
const reader = new FileReader();
46
+
reader.onload = () => resolve(reader.result);
47
+
reader.onerror = reject;
48
+
reader.readAsDataURL(file);
49
+
});
50
+
}
51
+
52
+
function getBase64Size(base64) {
53
+
const str = base64.split(',')[1] || base64;
54
+
return Math.ceil((str.length * 3) / 4);
55
+
}
56
+
57
+
function createResizedImage(dataUrl, options) {
58
+
return new Promise((resolve, reject) => {
59
+
const img = new Image();
60
+
img.onload = () => {
61
+
const scale = Math.min(
62
+
options.width / img.width,
63
+
options.height / img.height,
64
+
1
65
+
);
66
+
const w = Math.round(img.width * scale);
67
+
const h = Math.round(img.height * scale);
68
+
69
+
const canvas = document.createElement('canvas');
70
+
canvas.width = w;
71
+
canvas.height = h;
72
+
const ctx = canvas.getContext('2d');
73
+
74
+
ctx.fillStyle = '#fff';
75
+
ctx.fillRect(0, 0, w, h);
76
+
ctx.imageSmoothingEnabled = true;
77
+
ctx.imageSmoothingQuality = 'high';
78
+
ctx.drawImage(img, 0, 0, w, h);
79
+
80
+
resolve({
81
+
dataUrl: canvas.toDataURL('image/jpeg', options.quality),
82
+
width: w,
83
+
height: h
84
+
});
85
+
};
86
+
img.onerror = reject;
87
+
img.src = dataUrl;
88
+
});
89
+
}
90
+
91
+
export async function resizeImage(dataUrl, opts) {
92
+
let bestResult = null;
93
+
let minQuality = 0;
94
+
let maxQuality = 100;
95
+
96
+
while (maxQuality - minQuality > 1) {
97
+
const quality = Math.round((minQuality + maxQuality) / 2);
98
+
const result = await createResizedImage(dataUrl, {
99
+
width: opts.width,
100
+
height: opts.height,
101
+
quality: quality / 100
102
+
});
103
+
104
+
const size = getBase64Size(result.dataUrl);
105
+
if (size <= opts.maxSize) {
106
+
bestResult = result;
107
+
minQuality = quality;
108
+
} else {
109
+
maxQuality = quality;
110
+
}
111
+
}
112
+
113
+
if (!bestResult) {
114
+
throw new Error('Failed to compress image within size limit');
115
+
}
116
+
117
+
return bestResult;
118
+
}
119
+
120
+
export async function processPhotos(files) {
121
+
const processed = [];
122
+
for (const file of files) {
123
+
const dataUrl = await readFileAsDataURL(file);
124
+
const resized = await resizeImage(dataUrl, {
125
+
width: 2000,
126
+
height: 2000,
127
+
maxSize: 900000
128
+
});
129
+
processed.push({
130
+
dataUrl: resized.dataUrl,
131
+
width: resized.width,
132
+
height: resized.height
133
+
});
134
+
}
135
+
return processed;
136
+
}
137
+
```
138
+
139
+
**Step 2: Commit**
140
+
141
+
```bash
142
+
git add src/utils/image-resize.js
143
+
git commit -m "feat: add image resize utility"
144
+
```
145
+
146
+
---
147
+
148
+
### Task 3: Create Draft Gallery Service
149
+
150
+
**Files:**
151
+
- Create: `src/services/draft-gallery.js`
152
+
153
+
**Step 1: Create the draft gallery service**
154
+
155
+
```javascript
156
+
class DraftGalleryService {
157
+
#photos = [];
158
+
159
+
setPhotos(photos) {
160
+
this.#photos = [...photos];
161
+
}
162
+
163
+
getPhotos() {
164
+
return this.#photos;
165
+
}
166
+
167
+
clear() {
168
+
this.#photos = [];
169
+
}
170
+
171
+
get hasPhotos() {
172
+
return this.#photos.length > 0;
173
+
}
174
+
}
175
+
176
+
export const draftGallery = new DraftGalleryService();
177
+
```
178
+
179
+
**Step 2: Commit**
180
+
181
+
```bash
182
+
git add src/services/draft-gallery.js
183
+
git commit -m "feat: add draft gallery service"
184
+
```
185
+
186
+
---
187
+
188
+
### Task 4: Add Upload Methods to Auth Service
189
+
190
+
**Files:**
191
+
- Modify: `src/services/auth.js`
192
+
193
+
**Step 1: Add getClient method**
194
+
195
+
Add before the closing brace of the class:
196
+
197
+
```javascript
198
+
getClient() {
199
+
return this.#client;
200
+
}
201
+
```
202
+
203
+
**Step 2: Commit**
204
+
205
+
```bash
206
+
git add src/services/auth.js
207
+
git commit -m "feat: expose auth client for record creation"
208
+
```
209
+
210
+
---
211
+
212
+
### Task 5: Update Bottom Nav with Plus Button
213
+
214
+
**Files:**
215
+
- Modify: `src/components/organisms/grain-bottom-nav.js`
216
+
217
+
**Step 1: Add imports**
218
+
219
+
Add after line 3 (after auth import):
220
+
221
+
```javascript
222
+
import { draftGallery } from '../../services/draft-gallery.js';
223
+
import { processPhotos } from '../../utils/image-resize.js';
224
+
```
225
+
226
+
**Step 2: Add file input ref property**
227
+
228
+
Update static properties to:
229
+
230
+
```javascript
231
+
static properties = {
232
+
_user: { state: true },
233
+
_processing: { state: true }
234
+
};
235
+
```
236
+
237
+
**Step 3: Update constructor**
238
+
239
+
```javascript
240
+
constructor() {
241
+
super();
242
+
this._user = auth.user;
243
+
this._processing = false;
244
+
this._onNavigate = () => this.requestUpdate();
245
+
}
246
+
```
247
+
248
+
**Step 4: Add styles for plus button and processing state**
249
+
250
+
Add to static styles after `button.active`:
251
+
252
+
```css
253
+
button.plus {
254
+
background: none;
255
+
border: none;
256
+
padding: 8px;
257
+
cursor: pointer;
258
+
color: var(--color-text-secondary);
259
+
}
260
+
button.plus:disabled {
261
+
opacity: 0.5;
262
+
cursor: not-allowed;
263
+
}
264
+
input[type="file"] {
265
+
display: none;
266
+
}
267
+
```
268
+
269
+
**Step 5: Add handleCreate and handleFilesSelected methods**
270
+
271
+
Add after `#handleProfile` method:
272
+
273
+
```javascript
274
+
#handleCreate() {
275
+
this.shadowRoot.getElementById('photo-input').click();
276
+
}
277
+
278
+
async #handleFilesSelected(e) {
279
+
const files = Array.from(e.target.files);
280
+
e.target.value = '';
281
+
282
+
if (files.length === 0) return;
283
+
if (files.length > 10) {
284
+
alert('Maximum 10 photos allowed');
285
+
return;
286
+
}
287
+
288
+
try {
289
+
this._processing = true;
290
+
const processed = await processPhotos(files);
291
+
draftGallery.setPhotos(processed);
292
+
router.push('/create');
293
+
} catch (err) {
294
+
console.error('Failed to process photos:', err);
295
+
alert('Failed to process photos. Please try again.');
296
+
} finally {
297
+
this._processing = false;
298
+
}
299
+
}
300
+
```
301
+
302
+
**Step 6: Update render method**
303
+
304
+
Replace the render method:
305
+
306
+
```javascript
307
+
render() {
308
+
return html`
309
+
<nav>
310
+
<button
311
+
class=${this.#isHome ? 'active' : ''}
312
+
@click=${this.#handleHome}
313
+
>
314
+
<grain-icon name=${this.#isHome ? 'home' : 'homeLine'} size="20"></grain-icon>
315
+
</button>
316
+
${this._user ? html`
317
+
<button
318
+
class="plus"
319
+
@click=${this.#handleCreate}
320
+
?disabled=${this._processing}
321
+
>
322
+
<grain-icon name="plus" size="20"></grain-icon>
323
+
</button>
324
+
<input
325
+
type="file"
326
+
id="photo-input"
327
+
accept="image/*"
328
+
multiple
329
+
@change=${this.#handleFilesSelected}
330
+
>
331
+
` : ''}
332
+
<button
333
+
class=${this.#isOwnProfile ? 'active' : ''}
334
+
@click=${this.#handleProfile}
335
+
>
336
+
<grain-icon name=${this.#isOwnProfile ? 'userFilled' : 'user'} size="20"></grain-icon>
337
+
</button>
338
+
</nav>
339
+
`;
340
+
}
341
+
```
342
+
343
+
**Step 7: Commit**
344
+
345
+
```bash
346
+
git add src/components/organisms/grain-bottom-nav.js
347
+
git commit -m "feat: add plus button to bottom nav for gallery creation"
348
+
```
349
+
350
+
---
351
+
352
+
### Task 6: Create Gallery Page Component
353
+
354
+
**Files:**
355
+
- Create: `src/components/pages/grain-create-gallery.js`
356
+
357
+
**Step 1: Create the create gallery page**
358
+
359
+
```javascript
360
+
import { LitElement, html, css } from 'lit';
361
+
import { router } from '../../router.js';
362
+
import { auth } from '../../services/auth.js';
363
+
import { draftGallery } from '../../services/draft-gallery.js';
364
+
import '../atoms/grain-icon.js';
365
+
366
+
export class GrainCreateGallery extends LitElement {
367
+
static properties = {
368
+
_photos: { state: true },
369
+
_title: { state: true },
370
+
_description: { state: true },
371
+
_posting: { state: true },
372
+
_error: { state: true }
373
+
};
374
+
375
+
static styles = css`
376
+
:host {
377
+
display: block;
378
+
min-height: 100vh;
379
+
min-height: 100dvh;
380
+
}
381
+
.header {
382
+
display: flex;
383
+
align-items: center;
384
+
justify-content: space-between;
385
+
padding: var(--space-sm);
386
+
border-bottom: 1px solid var(--color-border);
387
+
}
388
+
.back-button {
389
+
background: none;
390
+
border: none;
391
+
padding: 8px;
392
+
margin-left: -8px;
393
+
cursor: pointer;
394
+
color: var(--color-text-primary);
395
+
}
396
+
.post-button {
397
+
background: var(--color-accent, #0066cc);
398
+
color: white;
399
+
border: none;
400
+
padding: 8px 16px;
401
+
border-radius: 6px;
402
+
font-weight: var(--font-weight-semibold);
403
+
cursor: pointer;
404
+
}
405
+
.post-button:disabled {
406
+
opacity: 0.5;
407
+
cursor: not-allowed;
408
+
}
409
+
.photo-strip {
410
+
display: flex;
411
+
gap: var(--space-xs);
412
+
padding: var(--space-sm);
413
+
overflow-x: auto;
414
+
border-bottom: 1px solid var(--color-border);
415
+
}
416
+
.photo-thumb {
417
+
position: relative;
418
+
flex-shrink: 0;
419
+
}
420
+
.photo-thumb img {
421
+
width: 80px;
422
+
height: 80px;
423
+
object-fit: cover;
424
+
border-radius: 4px;
425
+
}
426
+
.remove-photo {
427
+
position: absolute;
428
+
top: -6px;
429
+
right: -6px;
430
+
width: 20px;
431
+
height: 20px;
432
+
border-radius: 50%;
433
+
background: var(--color-text-primary);
434
+
color: var(--color-bg-primary);
435
+
border: none;
436
+
cursor: pointer;
437
+
font-size: 12px;
438
+
display: flex;
439
+
align-items: center;
440
+
justify-content: center;
441
+
}
442
+
.form {
443
+
padding: var(--space-sm);
444
+
}
445
+
.form input,
446
+
.form textarea {
447
+
width: 100%;
448
+
padding: var(--space-sm);
449
+
border: 1px solid var(--color-border);
450
+
border-radius: 6px;
451
+
background: var(--color-bg-primary);
452
+
color: var(--color-text-primary);
453
+
font-size: var(--font-size-sm);
454
+
font-family: inherit;
455
+
margin-bottom: var(--space-sm);
456
+
box-sizing: border-box;
457
+
}
458
+
.form textarea {
459
+
min-height: 100px;
460
+
resize: vertical;
461
+
}
462
+
.form input:focus,
463
+
.form textarea:focus {
464
+
outline: none;
465
+
border-color: var(--color-accent, #0066cc);
466
+
}
467
+
.error {
468
+
color: #ff4444;
469
+
padding: var(--space-sm);
470
+
text-align: center;
471
+
}
472
+
.char-count {
473
+
font-size: var(--font-size-xs);
474
+
color: var(--color-text-secondary);
475
+
text-align: right;
476
+
margin-top: -8px;
477
+
margin-bottom: var(--space-sm);
478
+
}
479
+
`;
480
+
481
+
constructor() {
482
+
super();
483
+
this._photos = [];
484
+
this._title = '';
485
+
this._description = '';
486
+
this._posting = false;
487
+
this._error = null;
488
+
}
489
+
490
+
connectedCallback() {
491
+
super.connectedCallback();
492
+
this._photos = draftGallery.getPhotos();
493
+
if (!this._photos.length) {
494
+
router.push('/');
495
+
}
496
+
}
497
+
498
+
#handleBack() {
499
+
if (confirm('Discard this gallery?')) {
500
+
draftGallery.clear();
501
+
history.back();
502
+
}
503
+
}
504
+
505
+
#removePhoto(index) {
506
+
this._photos = this._photos.filter((_, i) => i !== index);
507
+
if (this._photos.length === 0) {
508
+
draftGallery.clear();
509
+
router.push('/');
510
+
}
511
+
}
512
+
513
+
#handleTitleChange(e) {
514
+
this._title = e.target.value.slice(0, 100);
515
+
}
516
+
517
+
#handleDescriptionChange(e) {
518
+
this._description = e.target.value.slice(0, 1000);
519
+
}
520
+
521
+
get #canPost() {
522
+
return this._title.trim().length > 0 && this._photos.length > 0 && !this._posting;
523
+
}
524
+
525
+
async #handlePost() {
526
+
if (!this.#canPost) return;
527
+
528
+
this._posting = true;
529
+
this._error = null;
530
+
531
+
try {
532
+
const client = auth.getClient();
533
+
const now = new Date().toISOString();
534
+
535
+
// Upload photos and create photo records
536
+
const photoUris = [];
537
+
for (const photo of this._photos) {
538
+
// Upload blob
539
+
const base64Data = photo.dataUrl.split(',')[1];
540
+
const blobResult = await client.uploadBlob(base64Data, 'image/jpeg');
541
+
542
+
// Create photo record
543
+
const photoRecord = await client.createRecord('social.grain.photo', {
544
+
photo: {
545
+
$type: 'blob',
546
+
ref: { $link: blobResult.ref },
547
+
mimeType: blobResult.mimeType,
548
+
size: blobResult.size
549
+
},
550
+
aspectRatio: {
551
+
width: photo.width,
552
+
height: photo.height
553
+
},
554
+
createdAt: now
555
+
});
556
+
557
+
photoUris.push(photoRecord.uri);
558
+
}
559
+
560
+
// Create gallery record
561
+
const galleryRecord = await client.createRecord('social.grain.gallery', {
562
+
title: this._title.trim(),
563
+
description: this._description.trim() || undefined,
564
+
createdAt: now
565
+
});
566
+
567
+
// Create gallery items linking photos to gallery
568
+
for (let i = 0; i < photoUris.length; i++) {
569
+
await client.createRecord('social.grain.gallery.item', {
570
+
gallery: galleryRecord.uri,
571
+
item: photoUris[i],
572
+
position: i,
573
+
createdAt: now
574
+
});
575
+
}
576
+
577
+
// Clear draft and navigate to new gallery
578
+
draftGallery.clear();
579
+
const rkey = galleryRecord.uri.split('/').pop();
580
+
router.push(`/profile/${auth.user.handle}/gallery/${rkey}`);
581
+
582
+
} catch (err) {
583
+
console.error('Failed to create gallery:', err);
584
+
this._error = err.message || 'Failed to create gallery. Please try again.';
585
+
} finally {
586
+
this._posting = false;
587
+
}
588
+
}
589
+
590
+
render() {
591
+
return html`
592
+
<div class="header">
593
+
<button class="back-button" @click=${this.#handleBack}>
594
+
<grain-icon name="back" size="20"></grain-icon>
595
+
</button>
596
+
<button
597
+
class="post-button"
598
+
?disabled=${!this.#canPost}
599
+
@click=${this.#handlePost}
600
+
>
601
+
${this._posting ? 'Posting...' : 'Post'}
602
+
</button>
603
+
</div>
604
+
605
+
<div class="photo-strip">
606
+
${this._photos.map((photo, i) => html`
607
+
<div class="photo-thumb">
608
+
<img src=${photo.dataUrl} alt="Photo ${i + 1}">
609
+
<button class="remove-photo" @click=${() => this.#removePhoto(i)}>x</button>
610
+
</div>
611
+
`)}
612
+
</div>
613
+
614
+
${this._error ? html`<p class="error">${this._error}</p>` : ''}
615
+
616
+
<div class="form">
617
+
<input
618
+
type="text"
619
+
placeholder="Add a title..."
620
+
.value=${this._title}
621
+
@input=${this.#handleTitleChange}
622
+
maxlength="100"
623
+
>
624
+
<div class="char-count">${this._title.length}/100</div>
625
+
626
+
<textarea
627
+
placeholder="Add a description (optional)..."
628
+
.value=${this._description}
629
+
@input=${this.#handleDescriptionChange}
630
+
maxlength="1000"
631
+
></textarea>
632
+
<div class="char-count">${this._description.length}/1000</div>
633
+
</div>
634
+
`;
635
+
}
636
+
}
637
+
638
+
customElements.define('grain-create-gallery', GrainCreateGallery);
639
+
```
640
+
641
+
**Step 2: Commit**
642
+
643
+
```bash
644
+
git add src/components/pages/grain-create-gallery.js
645
+
git commit -m "feat: add create gallery page"
646
+
```
647
+
648
+
---
649
+
650
+
### Task 7: Register Create Route
651
+
652
+
**Files:**
653
+
- Modify: `src/components/pages/grain-app.js`
654
+
655
+
**Step 1: Import create gallery page**
656
+
657
+
Add after grain-settings import:
658
+
659
+
```javascript
660
+
import './grain-create-gallery.js';
661
+
```
662
+
663
+
**Step 2: Register route**
664
+
665
+
Add after settings route, before wildcard:
666
+
667
+
```javascript
668
+
.register('/create', 'grain-create-gallery')
669
+
```
670
+
671
+
**Step 3: Commit**
672
+
673
+
```bash
674
+
git add src/components/pages/grain-app.js
675
+
git commit -m "feat: register create gallery route"
676
+
```
677
+
678
+
---
679
+
680
+
### Task 8: Test Create Gallery Flow
681
+
682
+
**Step 1: Manual testing checklist**
683
+
684
+
1. Log in to the app
685
+
2. Verify + button appears in center of bottom nav
686
+
3. Tap + button, verify file picker opens
687
+
4. Select 1-3 photos, verify navigation to /create
688
+
5. Verify photos appear in thumbnail strip
689
+
6. Try removing a photo with x button
690
+
7. Enter a title, verify Post button enables
691
+
8. Add optional description
692
+
9. Tap Post, verify loading state
693
+
10. Verify navigation to new gallery detail page
694
+
11. Verify gallery shows in your profile grid
695
+
696
+
**Step 2: Test edge cases**
697
+
698
+
1. Select more than 10 photos - should show alert
699
+
2. Navigate to /create directly without photos - should redirect home
700
+
3. Remove all photos on create page - should redirect home
701
+
4. Try posting without title - Post button should stay disabled
702
+
703
+
**Step 3: Commit any fixes**
704
+
705
+
```bash
706
+
git add -A
707
+
git commit -m "fix: create gallery flow polish"
708
+
```