docs: add create gallery implementation plan

Changed files
+708
docs
+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 + ```