WIP PWA for Grain
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add edit profile page implementation plan

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+712
+712
docs/plans/2025-12-26-edit-profile.md
··· 1 + # Edit Profile Page Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add an edit profile page accessible via settings that allows users to update their display name, description, and avatar. 6 + 7 + **Architecture:** Create a new `grain-edit-profile` page component with form inputs for each field, using existing image resize utilities for avatar uploads. Add a link from the settings page and register the route at `/settings/profile`. 8 + 9 + **Tech Stack:** Lit 3.x, GraphQL mutations via quickslice-client, existing image-resize utility 10 + 11 + --- 12 + 13 + ## Task 1: Create grain-textarea Atom 14 + 15 + **Files:** 16 + - Create: `src/components/atoms/grain-textarea.js` 17 + 18 + **Step 1: Create the textarea component** 19 + 20 + ```javascript 21 + import { LitElement, html, css } from 'lit'; 22 + 23 + export class GrainTextarea extends LitElement { 24 + static properties = { 25 + placeholder: { type: String }, 26 + value: { type: String }, 27 + maxlength: { type: Number } 28 + }; 29 + 30 + static styles = css` 31 + :host { 32 + display: block; 33 + } 34 + textarea { 35 + width: 100%; 36 + padding: var(--space-sm); 37 + border: 1px solid var(--color-border); 38 + border-radius: 6px; 39 + background: var(--color-bg-primary); 40 + color: var(--color-text-primary); 41 + font-size: var(--font-size-sm); 42 + font-family: inherit; 43 + box-sizing: border-box; 44 + min-height: 100px; 45 + resize: vertical; 46 + } 47 + textarea::placeholder { 48 + color: var(--color-text-secondary); 49 + } 50 + textarea:focus { 51 + outline: none; 52 + border-color: var(--color-text-secondary); 53 + } 54 + .char-count { 55 + font-size: var(--font-size-xs); 56 + color: var(--color-text-secondary); 57 + text-align: right; 58 + margin-top: 4px; 59 + } 60 + `; 61 + 62 + constructor() { 63 + super(); 64 + this.placeholder = ''; 65 + this.value = ''; 66 + this.maxlength = null; 67 + } 68 + 69 + #handleInput(e) { 70 + this.value = this.maxlength 71 + ? e.target.value.slice(0, this.maxlength) 72 + : e.target.value; 73 + this.dispatchEvent(new CustomEvent('input', { 74 + detail: { value: this.value }, 75 + bubbles: true, 76 + composed: true 77 + })); 78 + } 79 + 80 + render() { 81 + return html` 82 + <textarea 83 + placeholder=${this.placeholder} 84 + .value=${this.value} 85 + @input=${this.#handleInput} 86 + maxlength=${this.maxlength || ''} 87 + ></textarea> 88 + ${this.maxlength ? html` 89 + <div class="char-count">${this.value.length}/${this.maxlength}</div> 90 + ` : ''} 91 + `; 92 + } 93 + } 94 + 95 + customElements.define('grain-textarea', GrainTextarea); 96 + ``` 97 + 98 + **Step 2: Commit** 99 + 100 + ```bash 101 + git add src/components/atoms/grain-textarea.js 102 + git commit -m "feat: add grain-textarea atom with character counter" 103 + ``` 104 + 105 + --- 106 + 107 + ## Task 2: Add updateProfile Method to grain-api.js 108 + 109 + **Files:** 110 + - Modify: `src/services/grain-api.js` 111 + 112 + **Step 1: Add the updateProfile method** 113 + 114 + Add this method to the GrainApiService class (before the closing brace of the class): 115 + 116 + ```javascript 117 + async getCurrentProfile() { 118 + const query = ` 119 + query { 120 + viewer { 121 + did 122 + handle 123 + socialGrainActorProfileByDid { 124 + displayName 125 + description 126 + avatar { url } 127 + } 128 + } 129 + } 130 + `; 131 + 132 + const response = await this.#execute(query, {}); 133 + const viewer = response.data?.viewer; 134 + const profile = viewer?.socialGrainActorProfileByDid; 135 + 136 + return { 137 + did: viewer?.did || '', 138 + handle: viewer?.handle || '', 139 + displayName: profile?.displayName || '', 140 + description: profile?.description || '', 141 + avatarUrl: profile?.avatar?.url || '' 142 + }; 143 + } 144 + ``` 145 + 146 + **Step 2: Commit** 147 + 148 + ```bash 149 + git add src/services/grain-api.js 150 + git commit -m "feat: add getCurrentProfile method to grain-api" 151 + ``` 152 + 153 + --- 154 + 155 + ## Task 3: Create grain-edit-profile Page 156 + 157 + **Files:** 158 + - Create: `src/components/pages/grain-edit-profile.js` 159 + 160 + **Step 1: Create the edit profile page** 161 + 162 + ```javascript 163 + import { LitElement, html, css } from 'lit'; 164 + import { router } from '../../router.js'; 165 + import { auth } from '../../services/auth.js'; 166 + import { grainApi } from '../../services/grain-api.js'; 167 + import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; 168 + import '../atoms/grain-icon.js'; 169 + import '../atoms/grain-button.js'; 170 + import '../atoms/grain-input.js'; 171 + import '../atoms/grain-textarea.js'; 172 + import '../atoms/grain-avatar.js'; 173 + 174 + const UPLOAD_BLOB_MUTATION = ` 175 + mutation UploadBlob($data: String!, $mimeType: String!) { 176 + uploadBlob(data: $data, mimeType: $mimeType) { 177 + ref 178 + mimeType 179 + size 180 + } 181 + } 182 + `; 183 + 184 + const UPDATE_PROFILE_MUTATION = ` 185 + mutation UpdateProfile($input: SocialGrainActorProfileInput!) { 186 + updateSocialGrainActorProfile(input: $input) { 187 + uri 188 + } 189 + } 190 + `; 191 + 192 + export class GrainEditProfile extends LitElement { 193 + static properties = { 194 + _loading: { state: true }, 195 + _saving: { state: true }, 196 + _error: { state: true }, 197 + _originalProfile: { state: true }, 198 + _displayName: { state: true }, 199 + _description: { state: true }, 200 + _avatarUrl: { state: true }, 201 + _newAvatarDataUrl: { state: true }, 202 + _avatarRemoved: { state: true } 203 + }; 204 + 205 + static styles = css` 206 + :host { 207 + display: block; 208 + max-width: var(--feed-max-width); 209 + margin: 0 auto; 210 + min-height: 100vh; 211 + min-height: 100dvh; 212 + padding-bottom: 80px; 213 + background: var(--color-bg-primary); 214 + } 215 + .header { 216 + display: flex; 217 + align-items: center; 218 + gap: var(--space-sm); 219 + padding: var(--space-md) var(--space-sm); 220 + } 221 + @media (min-width: 600px) { 222 + .header { 223 + padding-left: 0; 224 + padding-right: 0; 225 + } 226 + } 227 + .back-button { 228 + background: none; 229 + border: none; 230 + padding: 8px; 231 + cursor: pointer; 232 + color: var(--color-text-primary); 233 + margin-left: -8px; 234 + } 235 + h1 { 236 + font-size: var(--font-size-md); 237 + font-weight: var(--font-weight-semibold); 238 + color: var(--color-text-primary); 239 + margin: 0; 240 + } 241 + .content { 242 + padding: 0 var(--space-sm); 243 + } 244 + @media (min-width: 600px) { 245 + .content { 246 + padding: 0; 247 + } 248 + } 249 + .avatar-section { 250 + display: flex; 251 + flex-direction: column; 252 + align-items: center; 253 + margin-bottom: var(--space-lg); 254 + } 255 + .avatar-wrapper { 256 + position: relative; 257 + cursor: pointer; 258 + } 259 + .avatar-overlay { 260 + position: absolute; 261 + bottom: 0; 262 + right: 0; 263 + width: 28px; 264 + height: 28px; 265 + border-radius: 50%; 266 + background: var(--color-bg-primary); 267 + border: 2px solid var(--color-border); 268 + display: flex; 269 + align-items: center; 270 + justify-content: center; 271 + color: var(--color-text-primary); 272 + } 273 + .avatar-preview { 274 + width: 80px; 275 + height: 80px; 276 + border-radius: 50%; 277 + object-fit: cover; 278 + background: var(--color-bg-elevated); 279 + } 280 + .remove-avatar { 281 + background: none; 282 + border: none; 283 + color: var(--color-danger, #dc3545); 284 + font-size: var(--font-size-sm); 285 + cursor: pointer; 286 + margin-top: var(--space-xs); 287 + padding: var(--space-xs); 288 + } 289 + .remove-avatar:hover { 290 + text-decoration: underline; 291 + } 292 + input[type="file"] { 293 + display: none; 294 + } 295 + .form-group { 296 + margin-bottom: var(--space-md); 297 + } 298 + .form-group label { 299 + display: block; 300 + font-size: var(--font-size-sm); 301 + font-weight: var(--font-weight-medium); 302 + color: var(--color-text-primary); 303 + margin-bottom: var(--space-xs); 304 + } 305 + .char-count { 306 + font-size: var(--font-size-xs); 307 + color: var(--color-text-secondary); 308 + text-align: right; 309 + margin-top: 4px; 310 + } 311 + .save-section { 312 + padding: var(--space-md) var(--space-sm); 313 + border-top: 1px solid var(--color-border); 314 + margin-top: var(--space-lg); 315 + } 316 + @media (min-width: 600px) { 317 + .save-section { 318 + padding-left: 0; 319 + padding-right: 0; 320 + } 321 + } 322 + .error { 323 + color: var(--color-danger, #dc3545); 324 + font-size: var(--font-size-sm); 325 + padding: var(--space-sm); 326 + text-align: center; 327 + } 328 + .loading { 329 + display: flex; 330 + justify-content: center; 331 + padding: var(--space-xl); 332 + } 333 + `; 334 + 335 + constructor() { 336 + super(); 337 + this._loading = true; 338 + this._saving = false; 339 + this._error = null; 340 + this._originalProfile = null; 341 + this._displayName = ''; 342 + this._description = ''; 343 + this._avatarUrl = ''; 344 + this._newAvatarDataUrl = null; 345 + this._avatarRemoved = false; 346 + } 347 + 348 + async connectedCallback() { 349 + super.connectedCallback(); 350 + await this.#loadProfile(); 351 + } 352 + 353 + async #loadProfile() { 354 + try { 355 + const profile = await grainApi.getCurrentProfile(); 356 + this._originalProfile = profile; 357 + this._displayName = profile.displayName; 358 + this._description = profile.description; 359 + this._avatarUrl = profile.avatarUrl; 360 + } catch (err) { 361 + console.error('Failed to load profile:', err); 362 + this._error = 'Failed to load profile'; 363 + } finally { 364 + this._loading = false; 365 + } 366 + } 367 + 368 + #goBack() { 369 + if (this.#isDirty) { 370 + if (!confirm('You have unsaved changes. Leave anyway?')) { 371 + return; 372 + } 373 + } 374 + history.back(); 375 + } 376 + 377 + get #isDirty() { 378 + if (!this._originalProfile) return false; 379 + if (this._displayName !== this._originalProfile.displayName) return true; 380 + if (this._description !== this._originalProfile.description) return true; 381 + if (this._newAvatarDataUrl) return true; 382 + if (this._avatarRemoved && this._originalProfile.avatarUrl) return true; 383 + return false; 384 + } 385 + 386 + #handleDisplayNameChange(e) { 387 + this._displayName = e.detail.value.slice(0, 64); 388 + } 389 + 390 + #handleDescriptionChange(e) { 391 + this._description = e.detail.value.slice(0, 256); 392 + } 393 + 394 + #handleAvatarClick() { 395 + this.shadowRoot.querySelector('#avatar-input').click(); 396 + } 397 + 398 + async #handleAvatarChange(e) { 399 + const file = e.target.files?.[0]; 400 + if (!file) return; 401 + 402 + try { 403 + const dataUrl = await readFileAsDataURL(file); 404 + const resized = await resizeImage(dataUrl, { 405 + width: 2000, 406 + height: 2000, 407 + maxSize: 900000 408 + }); 409 + this._newAvatarDataUrl = resized.dataUrl; 410 + this._avatarRemoved = false; 411 + } catch (err) { 412 + console.error('Failed to process avatar:', err); 413 + this._error = 'Failed to process image'; 414 + } 415 + 416 + // Reset file input 417 + e.target.value = ''; 418 + } 419 + 420 + #handleRemoveAvatar() { 421 + this._newAvatarDataUrl = null; 422 + this._avatarRemoved = true; 423 + } 424 + 425 + get #displayedAvatarUrl() { 426 + if (this._newAvatarDataUrl) return this._newAvatarDataUrl; 427 + if (this._avatarRemoved) return ''; 428 + return this._avatarUrl; 429 + } 430 + 431 + get #showRemoveButton() { 432 + return this._newAvatarDataUrl || (!this._avatarRemoved && this._avatarUrl); 433 + } 434 + 435 + async #handleSave() { 436 + if (!this.#isDirty || this._saving) return; 437 + 438 + this._saving = true; 439 + this._error = null; 440 + 441 + try { 442 + const client = auth.getClient(); 443 + const input = { 444 + displayName: this._displayName.trim() || null, 445 + description: this._description.trim() || null 446 + }; 447 + 448 + // Handle avatar 449 + if (this._newAvatarDataUrl) { 450 + // Upload new avatar 451 + const base64Data = this._newAvatarDataUrl.split(',')[1]; 452 + const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, { 453 + data: base64Data, 454 + mimeType: 'image/jpeg' 455 + }); 456 + 457 + if (!uploadResult.uploadBlob) { 458 + throw new Error('Failed to upload avatar'); 459 + } 460 + 461 + input.avatar = { 462 + $type: 'blob', 463 + ref: { $link: uploadResult.uploadBlob.ref }, 464 + mimeType: uploadResult.uploadBlob.mimeType, 465 + size: uploadResult.uploadBlob.size 466 + }; 467 + } else if (this._avatarRemoved) { 468 + input.avatar = null; 469 + } 470 + 471 + await client.mutate(UPDATE_PROFILE_MUTATION, { input }); 472 + 473 + // Navigate back to settings 474 + router.push('/settings'); 475 + 476 + } catch (err) { 477 + console.error('Failed to save profile:', err); 478 + this._error = err.message || 'Failed to save profile'; 479 + } finally { 480 + this._saving = false; 481 + } 482 + } 483 + 484 + render() { 485 + if (this._loading) { 486 + return html` 487 + <div class="header"> 488 + <button class="back-button" @click=${this.#goBack}> 489 + <grain-icon name="back" size="20"></grain-icon> 490 + </button> 491 + <h1>Edit Profile</h1> 492 + </div> 493 + <div class="loading"> 494 + <grain-spinner></grain-spinner> 495 + </div> 496 + `; 497 + } 498 + 499 + return html` 500 + <div class="header"> 501 + <button class="back-button" @click=${this.#goBack}> 502 + <grain-icon name="back" size="20"></grain-icon> 503 + </button> 504 + <h1>Edit Profile</h1> 505 + </div> 506 + 507 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 508 + 509 + <div class="content"> 510 + <div class="avatar-section"> 511 + <div class="avatar-wrapper" @click=${this.#handleAvatarClick}> 512 + ${this.#displayedAvatarUrl ? html` 513 + <img class="avatar-preview" src=${this.#displayedAvatarUrl} alt="Profile avatar"> 514 + ` : html` 515 + <grain-avatar size="lg"></grain-avatar> 516 + `} 517 + <div class="avatar-overlay"> 518 + <grain-icon name="camera" size="14"></grain-icon> 519 + </div> 520 + </div> 521 + ${this.#showRemoveButton ? html` 522 + <button class="remove-avatar" @click=${this.#handleRemoveAvatar}> 523 + Remove 524 + </button> 525 + ` : ''} 526 + <input 527 + type="file" 528 + id="avatar-input" 529 + accept="image/png,image/jpeg" 530 + @change=${this.#handleAvatarChange} 531 + > 532 + </div> 533 + 534 + <div class="form-group"> 535 + <label>Display Name</label> 536 + <grain-input 537 + placeholder="Display name" 538 + .value=${this._displayName} 539 + @input=${this.#handleDisplayNameChange} 540 + ></grain-input> 541 + <div class="char-count">${this._displayName.length}/64</div> 542 + </div> 543 + 544 + <div class="form-group"> 545 + <label>Bio</label> 546 + <grain-textarea 547 + placeholder="Tell us about yourself" 548 + .value=${this._description} 549 + .maxlength=${256} 550 + @input=${this.#handleDescriptionChange} 551 + ></grain-textarea> 552 + </div> 553 + </div> 554 + 555 + <div class="save-section"> 556 + <grain-button 557 + variant="primary" 558 + ?disabled=${!this.#isDirty} 559 + ?loading=${this._saving} 560 + loadingText="Saving..." 561 + @click=${this.#handleSave} 562 + >Save</grain-button> 563 + </div> 564 + `; 565 + } 566 + } 567 + 568 + customElements.define('grain-edit-profile', GrainEditProfile); 569 + ``` 570 + 571 + **Step 2: Commit** 572 + 573 + ```bash 574 + git add src/components/pages/grain-edit-profile.js 575 + git commit -m "feat: add grain-edit-profile page component" 576 + ``` 577 + 578 + --- 579 + 580 + ## Task 4: Add Camera Icon to grain-icon 581 + 582 + **Files:** 583 + - Modify: `src/components/atoms/grain-icon.js` 584 + 585 + **Step 1: Check if camera icon exists and add if needed** 586 + 587 + Open the file and check the icon mapping. If "camera" is not present, add it to the icon map: 588 + 589 + ```javascript 590 + camera: 'fa-solid fa-camera', 591 + ``` 592 + 593 + **Step 2: Commit (if changes made)** 594 + 595 + ```bash 596 + git add src/components/atoms/grain-icon.js 597 + git commit -m "feat: add camera icon to grain-icon" 598 + ``` 599 + 600 + --- 601 + 602 + ## Task 5: Add Edit Profile Link to Settings Page 603 + 604 + **Files:** 605 + - Modify: `src/components/pages/grain-settings.js` 606 + 607 + **Step 1: Import router** 608 + 609 + The router is already imported. No changes needed. 610 + 611 + **Step 2: Add method to navigate to edit profile** 612 + 613 + Add this method to the class (before render): 614 + 615 + ```javascript 616 + #goToEditProfile() { 617 + router.push('/settings/profile'); 618 + } 619 + ``` 620 + 621 + **Step 3: Add the Edit Profile row to the settings list** 622 + 623 + In the render method, add the Edit Profile row as the first item in the settings-list div, before the install app conditional: 624 + 625 + ```javascript 626 + <button class="settings-row" @click=${this.#goToEditProfile}> 627 + <grain-icon name="user" size="18"></grain-icon> 628 + Edit Profile 629 + </button> 630 + ``` 631 + 632 + **Step 4: Commit** 633 + 634 + ```bash 635 + git add src/components/pages/grain-settings.js 636 + git commit -m "feat: add edit profile link to settings page" 637 + ``` 638 + 639 + --- 640 + 641 + ## Task 6: Register Route in grain-app.js 642 + 643 + **Files:** 644 + - Modify: `src/components/pages/grain-app.js` 645 + 646 + **Step 1: Add import for the new page** 647 + 648 + After the line `import './grain-settings.js';`, add: 649 + 650 + ```javascript 651 + import './grain-edit-profile.js'; 652 + ``` 653 + 654 + **Step 2: Register the route** 655 + 656 + Add this line after `.register('/settings', 'grain-settings')`: 657 + 658 + ```javascript 659 + .register('/settings/profile', 'grain-edit-profile') 660 + ``` 661 + 662 + Important: The order matters. `/settings/profile` should come BEFORE `/settings` or be a more specific match. Since our router matches first match, we need to ensure the more specific route is registered first. Looking at the current order, we should add it right after the settings line. 663 + 664 + Actually, looking at the router implementation, routes are matched in order and `/settings/profile` has a different number of path parts than `/settings`, so order doesn't matter for these two. Add it after `/settings`: 665 + 666 + ```javascript 667 + .register('/settings', 'grain-settings') 668 + .register('/settings/profile', 'grain-edit-profile') 669 + ``` 670 + 671 + **Step 3: Commit** 672 + 673 + ```bash 674 + git add src/components/pages/grain-app.js 675 + git commit -m "feat: register /settings/profile route for edit profile" 676 + ``` 677 + 678 + --- 679 + 680 + ## Task 7: Add User Icon to grain-icon (if needed) 681 + 682 + **Files:** 683 + - Modify: `src/components/atoms/grain-icon.js` 684 + 685 + **Step 1: Check if user icon exists** 686 + 687 + Open the file and check if "user" is in the icon map. If not, add: 688 + 689 + ```javascript 690 + user: 'fa-solid fa-user', 691 + ``` 692 + 693 + **Step 2: Commit (if changes made)** 694 + 695 + ```bash 696 + git add src/components/atoms/grain-icon.js 697 + git commit -m "feat: add user icon to grain-icon" 698 + ``` 699 + 700 + --- 701 + 702 + ## Summary 703 + 704 + | Task | Description | Files | 705 + |------|-------------|-------| 706 + | 1 | Create grain-textarea atom | Create: `grain-textarea.js` | 707 + | 2 | Add getCurrentProfile to API | Modify: `grain-api.js` | 708 + | 3 | Create edit profile page | Create: `grain-edit-profile.js` | 709 + | 4 | Add camera icon | Modify: `grain-icon.js` | 710 + | 5 | Add link to settings | Modify: `grain-settings.js` | 711 + | 6 | Register route | Modify: `grain-app.js` | 712 + | 7 | Add user icon (if needed) | Modify: `grain-icon.js` |