feat: add avatar change with crop from profile page

Add ability to change avatar directly from profile page when viewing
own profile. Includes custom crop component with drag-to-position,
zoom slider, pinch-to-zoom, and keyboard accessibility.

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

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

Changed files
+741 -13
src
+541
src/components/molecules/grain-avatar-crop.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import '../atoms/grain-button.js'; 3 + import '../atoms/grain-icon.js'; 4 + import '../atoms/grain-spinner.js'; 5 + 6 + // Constants 7 + const DEFAULT_CROP_SIZE = 280; 8 + const MAX_ZOOM = 3; 9 + const OUTPUT_SIZE = 1000; 10 + const WHEEL_ZOOM_DELTA = 0.1; 11 + const KEY_PAN_DELTA = 10; 12 + 13 + /** 14 + * Avatar crop modal - allows user to position and zoom an image within a square frame 15 + * 16 + * @fires crop - When user confirms crop, detail contains { dataUrl: string } 17 + * @fires cancel - When user cancels 18 + * @fires error - When image fails to load 19 + */ 20 + export class GrainAvatarCrop extends LitElement { 21 + static properties = { 22 + open: { type: Boolean, reflect: true }, 23 + imageUrl: { type: String, attribute: 'image-url' }, 24 + _scale: { state: true }, 25 + _translateX: { state: true }, 26 + _translateY: { state: true }, 27 + _isDragging: { state: true }, 28 + _cropSize: { state: true }, 29 + _loading: { state: true }, 30 + _ready: { state: true } 31 + }; 32 + 33 + static styles = css` 34 + :host { 35 + display: none; 36 + } 37 + :host([open]) { 38 + display: block; 39 + } 40 + .overlay { 41 + position: fixed; 42 + top: 48px; 43 + bottom: calc(48px + env(safe-area-inset-bottom, 0px)); 44 + left: 50%; 45 + transform: translateX(-50%); 46 + width: 100%; 47 + max-width: var(--feed-max-width); 48 + background: var(--color-bg-primary); 49 + border-top: 1px solid var(--color-border); 50 + border-bottom: 1px solid var(--color-border); 51 + z-index: 1000; 52 + display: flex; 53 + flex-direction: column; 54 + align-items: center; 55 + justify-content: center; 56 + } 57 + .header { 58 + position: absolute; 59 + top: 0; 60 + left: 0; 61 + right: 0; 62 + display: flex; 63 + align-items: center; 64 + justify-content: space-between; 65 + padding: var(--space-md); 66 + color: var(--color-text-primary); 67 + } 68 + .header h2 { 69 + margin: 0; 70 + font-size: var(--font-size-md); 71 + font-weight: var(--font-weight-semibold); 72 + } 73 + .header-button { 74 + background: none; 75 + border: none; 76 + color: var(--color-text-primary); 77 + padding: 8px; 78 + cursor: pointer; 79 + font-size: var(--font-size-md); 80 + } 81 + .header-button:disabled { 82 + opacity: 0.5; 83 + cursor: not-allowed; 84 + } 85 + .crop-area { 86 + display: flex; 87 + align-items: center; 88 + justify-content: center; 89 + position: relative; 90 + overflow: hidden; 91 + touch-action: none; 92 + width: 100%; 93 + max-width: 400px; 94 + padding: var(--space-md); 95 + outline: none; 96 + } 97 + .image-container { 98 + position: relative; 99 + display: flex; 100 + align-items: center; 101 + justify-content: center; 102 + width: 100%; 103 + } 104 + .crop-image { 105 + max-width: 100%; 106 + max-height: 400px; 107 + user-select: none; 108 + -webkit-user-drag: none; 109 + } 110 + .mask { 111 + position: absolute; 112 + inset: 0; 113 + pointer-events: none; 114 + display: flex; 115 + align-items: center; 116 + justify-content: center; 117 + } 118 + .mask-square { 119 + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6); 120 + border: 2px solid rgba(255, 255, 255, 0.3); 121 + } 122 + .controls { 123 + position: absolute; 124 + bottom: 0; 125 + left: 0; 126 + right: 0; 127 + padding: var(--space-md); 128 + display: flex; 129 + flex-direction: column; 130 + gap: var(--space-md); 131 + } 132 + .zoom-control { 133 + display: flex; 134 + align-items: center; 135 + gap: var(--space-md); 136 + padding: 0 var(--space-md); 137 + } 138 + .zoom-control grain-icon { 139 + color: var(--color-text-secondary); 140 + opacity: 0.7; 141 + } 142 + .zoom-slider { 143 + flex: 1; 144 + -webkit-appearance: none; 145 + appearance: none; 146 + height: 4px; 147 + background: rgba(255, 255, 255, 0.3); 148 + border-radius: 2px; 149 + outline: none; 150 + } 151 + .zoom-slider::-webkit-slider-thumb { 152 + -webkit-appearance: none; 153 + appearance: none; 154 + width: 20px; 155 + height: 20px; 156 + background: white; 157 + border-radius: 50%; 158 + cursor: pointer; 159 + } 160 + .zoom-slider::-moz-range-thumb { 161 + width: 20px; 162 + height: 20px; 163 + background: white; 164 + border-radius: 50%; 165 + cursor: pointer; 166 + border: none; 167 + } 168 + .loading-container { 169 + display: flex; 170 + align-items: center; 171 + justify-content: center; 172 + min-height: 200px; 173 + } 174 + `; 175 + 176 + // Track load operations to handle race conditions 177 + #loadId = 0; 178 + #pinchStartDistance = 0; 179 + #pinchStartScale = 1; 180 + 181 + constructor() { 182 + super(); 183 + this.open = false; 184 + this.imageUrl = ''; 185 + this._scale = 1; 186 + this._translateX = 0; 187 + this._translateY = 0; 188 + this._isDragging = false; 189 + this._dragStart = { x: 0, y: 0 }; 190 + this._lastTranslate = { x: 0, y: 0 }; 191 + this._imageSize = { width: 0, height: 0 }; 192 + this._displayedSize = { width: 0, height: 0 }; 193 + this._minScale = 1; 194 + this._cropSize = DEFAULT_CROP_SIZE; 195 + this._loading = false; 196 + this._ready = false; 197 + } 198 + 199 + updated(changedProps) { 200 + if (changedProps.has('imageUrl') && this.imageUrl) { 201 + this.#loadImage(); 202 + } 203 + if (changedProps.has('open') && this.open) { 204 + this.#reset(); 205 + // Focus crop area for keyboard navigation 206 + requestAnimationFrame(() => { 207 + this.shadowRoot.querySelector('.crop-area')?.focus(); 208 + }); 209 + } 210 + } 211 + 212 + #reset() { 213 + this._scale = 1; 214 + this._translateX = 0; 215 + this._translateY = 0; 216 + this._ready = false; 217 + if (this.imageUrl) { 218 + this.#loadImage(); 219 + } 220 + } 221 + 222 + async #loadImage() { 223 + // Increment load ID to handle race conditions 224 + const currentLoadId = ++this.#loadId; 225 + 226 + this._loading = true; 227 + this._ready = false; 228 + 229 + try { 230 + const img = new Image(); 231 + img.src = this.imageUrl; 232 + await img.decode(); 233 + 234 + // Check if this load is still current (not stale) 235 + if (currentLoadId !== this.#loadId) return; 236 + 237 + this._imageSize = { width: img.naturalWidth, height: img.naturalHeight }; 238 + 239 + // Start at scale 1 240 + this._minScale = 1; 241 + this._scale = 1; 242 + this._translateX = 0; 243 + this._translateY = 0; 244 + 245 + // Image loaded successfully 246 + this._loading = false; 247 + 248 + // Wait for render then get displayed image size 249 + await this.updateComplete; 250 + 251 + // Wait for layout to complete (double RAF for reliable dimensions) 252 + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); 253 + 254 + // Check again after async operation 255 + if (currentLoadId !== this.#loadId) return; 256 + 257 + const displayedImg = this.shadowRoot.querySelector('.crop-image'); 258 + if (displayedImg && displayedImg.offsetWidth > 0) { 259 + this._displayedSize = { 260 + width: displayedImg.offsetWidth, 261 + height: displayedImg.offsetHeight 262 + }; 263 + // Crop size is the smaller of default or the smallest image dimension 264 + const minDimension = Math.min(this._displayedSize.width, this._displayedSize.height); 265 + this._cropSize = Math.min(DEFAULT_CROP_SIZE, minDimension); 266 + this._ready = true; 267 + } 268 + } catch (err) { 269 + console.error('Failed to load image:', err); 270 + if (currentLoadId === this.#loadId) { 271 + this._loading = false; 272 + this.dispatchEvent(new CustomEvent('error', { 273 + detail: { message: 'Failed to load image' }, 274 + bubbles: true, 275 + composed: true 276 + })); 277 + } 278 + } 279 + } 280 + 281 + #handlePointerDown(e) { 282 + if (e.target.tagName === 'INPUT') return; 283 + 284 + // Prevent default to avoid text selection and other browser behaviors 285 + e.preventDefault(); 286 + 287 + this._isDragging = true; 288 + this._dragStart = { x: e.clientX, y: e.clientY }; 289 + this._lastTranslate = { x: this._translateX, y: this._translateY }; 290 + 291 + // Capture pointer on the crop-area element for reliable drag tracking 292 + const cropArea = this.shadowRoot.querySelector('.crop-area'); 293 + cropArea?.setPointerCapture?.(e.pointerId); 294 + } 295 + 296 + #handlePointerMove(e) { 297 + if (!this._isDragging) return; 298 + 299 + // Prevent scrolling while dragging 300 + e.preventDefault(); 301 + 302 + const dx = e.clientX - this._dragStart.x; 303 + const dy = e.clientY - this._dragStart.y; 304 + 305 + this._translateX = this.#clampTranslateX(this._lastTranslate.x + dx); 306 + this._translateY = this.#clampTranslateY(this._lastTranslate.y + dy); 307 + } 308 + 309 + #handlePointerUp() { 310 + this._isDragging = false; 311 + } 312 + 313 + #handleTouchStart(e) { 314 + if (e.touches.length === 2) { 315 + // Pinch start - prevent default to avoid browser zoom 316 + e.preventDefault(); 317 + const dx = e.touches[0].clientX - e.touches[1].clientX; 318 + const dy = e.touches[0].clientY - e.touches[1].clientY; 319 + this.#pinchStartDistance = Math.hypot(dx, dy); 320 + this.#pinchStartScale = this._scale; 321 + } 322 + // Single touch is handled by pointer events 323 + } 324 + 325 + #handleTouchMove(e) { 326 + if (e.touches.length === 2 && this.#pinchStartDistance > 0) { 327 + e.preventDefault(); 328 + const dx = e.touches[0].clientX - e.touches[1].clientX; 329 + const dy = e.touches[0].clientY - e.touches[1].clientY; 330 + const distance = Math.hypot(dx, dy); 331 + const scaleFactor = distance / this.#pinchStartDistance; 332 + const newScale = Math.max(this._minScale, Math.min(MAX_ZOOM, this.#pinchStartScale * scaleFactor)); 333 + this._scale = newScale; 334 + 335 + this._translateX = this.#clampTranslateX(this._translateX); 336 + this._translateY = this.#clampTranslateY(this._translateY); 337 + } 338 + } 339 + 340 + #handleTouchEnd() { 341 + this.#pinchStartDistance = 0; 342 + } 343 + 344 + #clampTranslateX(x) { 345 + // Allow free movement if image size not yet calculated 346 + if (!this._displayedSize.width || !this._cropSize) return x; 347 + const scaledWidth = this._displayedSize.width * this._scale; 348 + const maxOffset = Math.max(0, (scaledWidth - this._cropSize) / 2); 349 + return Math.max(-maxOffset, Math.min(maxOffset, x)); 350 + } 351 + 352 + #clampTranslateY(y) { 353 + // Allow free movement if image size not yet calculated 354 + if (!this._displayedSize.height || !this._cropSize) return y; 355 + const scaledHeight = this._displayedSize.height * this._scale; 356 + const maxOffset = Math.max(0, (scaledHeight - this._cropSize) / 2); 357 + return Math.max(-maxOffset, Math.min(maxOffset, y)); 358 + } 359 + 360 + #handleZoom(e) { 361 + const newScale = parseFloat(e.target.value); 362 + this._scale = newScale; 363 + 364 + // Re-clamp translation after scale change 365 + this._translateX = this.#clampTranslateX(this._translateX); 366 + this._translateY = this.#clampTranslateY(this._translateY); 367 + } 368 + 369 + #handleWheel(e) { 370 + e.preventDefault(); 371 + const delta = e.deltaY > 0 ? -WHEEL_ZOOM_DELTA : WHEEL_ZOOM_DELTA; 372 + const newScale = Math.max(this._minScale, Math.min(MAX_ZOOM, this._scale + delta)); 373 + this._scale = newScale; 374 + 375 + this._translateX = this.#clampTranslateX(this._translateX); 376 + this._translateY = this.#clampTranslateY(this._translateY); 377 + } 378 + 379 + #handleKeyDown(e) { 380 + switch (e.key) { 381 + case 'Escape': 382 + e.preventDefault(); 383 + this.#handleCancel(); 384 + break; 385 + case 'Enter': 386 + e.preventDefault(); 387 + this.#handleConfirm(); 388 + break; 389 + case 'ArrowLeft': 390 + e.preventDefault(); 391 + this._translateX = this.#clampTranslateX(this._translateX + KEY_PAN_DELTA); 392 + break; 393 + case 'ArrowRight': 394 + e.preventDefault(); 395 + this._translateX = this.#clampTranslateX(this._translateX - KEY_PAN_DELTA); 396 + break; 397 + case 'ArrowUp': 398 + e.preventDefault(); 399 + this._translateY = this.#clampTranslateY(this._translateY + KEY_PAN_DELTA); 400 + break; 401 + case 'ArrowDown': 402 + e.preventDefault(); 403 + this._translateY = this.#clampTranslateY(this._translateY - KEY_PAN_DELTA); 404 + break; 405 + case '+': 406 + case '=': 407 + e.preventDefault(); 408 + this._scale = Math.min(MAX_ZOOM, this._scale + WHEEL_ZOOM_DELTA); 409 + this._translateX = this.#clampTranslateX(this._translateX); 410 + this._translateY = this.#clampTranslateY(this._translateY); 411 + break; 412 + case '-': 413 + case '_': 414 + e.preventDefault(); 415 + this._scale = Math.max(this._minScale, this._scale - WHEEL_ZOOM_DELTA); 416 + this._translateX = this.#clampTranslateX(this._translateX); 417 + this._translateY = this.#clampTranslateY(this._translateY); 418 + break; 419 + } 420 + } 421 + 422 + #handleCancel() { 423 + this.dispatchEvent(new CustomEvent('cancel', { bubbles: true, composed: true })); 424 + } 425 + 426 + async #handleConfirm() { 427 + // Guard against calling before image is ready 428 + if (!this._ready || !this._displayedSize.width || !this._displayedSize.height) { 429 + console.error('Image not fully loaded'); 430 + return; 431 + } 432 + 433 + const canvas = document.createElement('canvas'); 434 + canvas.width = OUTPUT_SIZE; 435 + canvas.height = OUTPUT_SIZE; 436 + const ctx = canvas.getContext('2d'); 437 + 438 + const img = this.shadowRoot.querySelector('.crop-image'); 439 + 440 + // Calculate the ratio between natural and displayed size 441 + const scaleRatioX = this._imageSize.width / this._displayedSize.width; 442 + const scaleRatioY = this._imageSize.height / this._displayedSize.height; 443 + 444 + // The visible crop area in displayed coordinates 445 + const displayedCropWidth = this._cropSize / this._scale; 446 + const displayedCropHeight = this._cropSize / this._scale; 447 + 448 + // Center of displayed image adjusted by translation 449 + const displayedCenterX = this._displayedSize.width / 2 - this._translateX / this._scale; 450 + const displayedCenterY = this._displayedSize.height / 2 - this._translateY / this._scale; 451 + 452 + // Convert to natural image coordinates 453 + const sx = (displayedCenterX - displayedCropWidth / 2) * scaleRatioX; 454 + const sy = (displayedCenterY - displayedCropHeight / 2) * scaleRatioY; 455 + const sw = displayedCropWidth * scaleRatioX; 456 + const sh = displayedCropHeight * scaleRatioY; 457 + 458 + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE); 459 + 460 + const dataUrl = canvas.toDataURL('image/jpeg', 0.9); 461 + 462 + this.dispatchEvent(new CustomEvent('crop', { 463 + detail: { dataUrl }, 464 + bubbles: true, 465 + composed: true 466 + })); 467 + } 468 + 469 + render() { 470 + if (!this.open) return null; 471 + 472 + return html` 473 + <div class="overlay"> 474 + <div class="header"> 475 + <button class="header-button" @click=${this.#handleCancel}>Cancel</button> 476 + <h2>Move and Scale</h2> 477 + <button 478 + class="header-button" 479 + @click=${this.#handleConfirm} 480 + ?disabled=${!this._ready} 481 + >Done</button> 482 + </div> 483 + 484 + ${this._loading ? html` 485 + <div class="loading-container"> 486 + <grain-spinner></grain-spinner> 487 + </div> 488 + ` : html` 489 + <div 490 + class="crop-area" 491 + tabindex="0" 492 + @pointerdown=${this.#handlePointerDown} 493 + @pointermove=${this.#handlePointerMove} 494 + @pointerup=${this.#handlePointerUp} 495 + @pointercancel=${this.#handlePointerUp} 496 + @touchstart=${this.#handleTouchStart} 497 + @touchmove=${this.#handleTouchMove} 498 + @touchend=${this.#handleTouchEnd} 499 + @wheel=${this.#handleWheel} 500 + @keydown=${this.#handleKeyDown} 501 + > 502 + <div class="image-container"> 503 + <img 504 + class="crop-image" 505 + src=${this.imageUrl} 506 + style="transform: translate(${this._translateX}px, ${this._translateY}px) scale(${this._scale})" 507 + draggable="false" 508 + alt="Image to crop" 509 + /> 510 + <div class="mask"> 511 + <div class="mask-square" style="width: ${this._cropSize}px; height: ${this._cropSize}px;"></div> 512 + </div> 513 + </div> 514 + </div> 515 + 516 + <div class="controls"> 517 + <div class="zoom-control"> 518 + <grain-icon name="image" size="16"></grain-icon> 519 + <input 520 + type="range" 521 + class="zoom-slider" 522 + min=${this._minScale} 523 + max=${MAX_ZOOM} 524 + step="0.01" 525 + .value=${String(this._scale)} 526 + @input=${this.#handleZoom} 527 + aria-label="Zoom level" 528 + aria-valuemin=${this._minScale} 529 + aria-valuemax=${MAX_ZOOM} 530 + aria-valuenow=${this._scale} 531 + /> 532 + <grain-icon name="image" size="24"></grain-icon> 533 + </div> 534 + </div> 535 + `} 536 + </div> 537 + `; 538 + } 539 + } 540 + 541 + customElements.define('grain-avatar-crop', GrainAvatarCrop);
+113 -12
src/components/organisms/grain-profile-header.js
··· 3 3 import { auth } from '../../services/auth.js'; 4 4 import { share } from '../../services/share.js'; 5 5 import { mutations } from '../../services/mutations.js'; 6 + import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; 6 7 import '../atoms/grain-avatar.js'; 7 8 import '../atoms/grain-icon.js'; 9 + import '../atoms/grain-spinner.js'; 8 10 import '../atoms/grain-toast.js'; 9 11 import '../molecules/grain-profile-stats.js'; 10 12 ··· 12 14 static properties = { 13 15 profile: { type: Object }, 14 16 _user: { state: true }, 15 - _followLoading: { state: true } 17 + _followLoading: { state: true }, 18 + _avatarUploading: { state: true } 16 19 }; 17 20 18 21 static styles = css` ··· 66 69 padding: 0; 67 70 cursor: pointer; 68 71 } 72 + .avatar-wrapper { 73 + position: relative; 74 + } 75 + .avatar-overlay { 76 + position: absolute; 77 + bottom: 0; 78 + right: 0; 79 + width: 28px; 80 + height: 28px; 81 + border-radius: 50%; 82 + background: var(--color-bg-primary); 83 + border: 2px solid var(--color-border); 84 + display: flex; 85 + align-items: center; 86 + justify-content: center; 87 + color: var(--color-text-primary); 88 + } 89 + .avatar-spinner { 90 + position: absolute; 91 + inset: 0; 92 + display: flex; 93 + align-items: center; 94 + justify-content: center; 95 + background: rgba(0, 0, 0, 0.5); 96 + border-radius: 50%; 97 + } 98 + input[type="file"] { 99 + display: none; 100 + } 69 101 .menu-button { 70 102 background: none; 71 103 border: none; ··· 99 131 super(); 100 132 this._user = auth.user; 101 133 this._followLoading = false; 134 + this._avatarUploading = false; 102 135 } 103 136 104 137 connectedCallback() { ··· 139 172 } else if (action === 'share') { 140 173 const result = await share(window.location.href); 141 174 if (result.success && result.method === 'clipboard') { 142 - this.shadowRoot.querySelector('grain-toast').show('Link copied'); 175 + this.shadowRoot.querySelector('grain-toast')?.show('Link copied'); 143 176 } 144 177 } 145 178 } 146 179 147 180 #handleAvatarClick() { 148 - this.dispatchEvent(new CustomEvent('avatar-click', { 149 - bubbles: true, 150 - composed: true 151 - })); 181 + if (this.#isOwnProfile) { 182 + this.shadowRoot.querySelector('#avatar-input').click(); 183 + } else { 184 + this.dispatchEvent(new CustomEvent('avatar-click', { 185 + bubbles: true, 186 + composed: true 187 + })); 188 + } 189 + } 190 + 191 + async #handleAvatarFileChange(e) { 192 + const input = e.target; 193 + const file = input.files?.[0]; 194 + if (!file) return; 195 + 196 + // Reset input immediately so same file can be selected again 197 + input.value = ''; 198 + 199 + try { 200 + const dataUrl = await readFileAsDataURL(file); 201 + const resized = await resizeImage(dataUrl, { 202 + width: 2000, 203 + height: 2000, 204 + maxSize: 900000 205 + }); 206 + // Emit event for parent to show crop modal 207 + this.dispatchEvent(new CustomEvent('avatar-crop-start', { 208 + detail: { imageUrl: resized.dataUrl }, 209 + bubbles: true, 210 + composed: true 211 + })); 212 + } catch (err) { 213 + console.error('Failed to process image:', err); 214 + this.shadowRoot.querySelector('grain-toast')?.show('Failed to process image'); 215 + } 216 + } 217 + 218 + async uploadCroppedAvatar(dataUrl) { 219 + this._avatarUploading = true; 220 + 221 + try { 222 + await mutations.updateAvatar(dataUrl, this.profile); 223 + this.shadowRoot.querySelector('grain-toast')?.show('Avatar updated'); 224 + this.dispatchEvent(new CustomEvent('avatar-updated', { 225 + bubbles: true, 226 + composed: true 227 + })); 228 + } catch (err) { 229 + console.error('Failed to update avatar:', err); 230 + this.shadowRoot.querySelector('grain-toast')?.show('Failed to update avatar'); 231 + } finally { 232 + this._avatarUploading = false; 233 + } 152 234 } 153 235 154 236 async #handleFollowClick() { ··· 171 253 }; 172 254 } catch (err) { 173 255 console.error('Failed to toggle follow:', err); 174 - this.shadowRoot.querySelector('grain-toast').show('Failed to update'); 256 + this.shadowRoot.querySelector('grain-toast')?.show('Failed to update'); 175 257 } finally { 176 258 this._followLoading = false; 177 259 } ··· 185 267 return html` 186 268 <div class="top-row"> 187 269 <button class="avatar-button" @click=${this.#handleAvatarClick}> 188 - <grain-avatar 189 - src=${avatarUrl || ''} 190 - alt=${handle || ''} 191 - size="lg" 192 - ></grain-avatar> 270 + <div class="avatar-wrapper"> 271 + <grain-avatar 272 + src=${avatarUrl || ''} 273 + alt=${handle || ''} 274 + size="lg" 275 + ></grain-avatar> 276 + ${this.#isOwnProfile ? html` 277 + <div class="avatar-overlay"> 278 + <grain-icon name="camera" size="14"></grain-icon> 279 + </div> 280 + ` : ''} 281 + ${this._avatarUploading ? html` 282 + <div class="avatar-spinner"> 283 + <grain-spinner size="24"></grain-spinner> 284 + </div> 285 + ` : ''} 286 + </div> 193 287 </button> 194 288 <div class="right-column"> 195 289 <div class="handle-row"> ··· 218 312 ${this.profile.viewerIsFollowing ? 'Following' : 'Follow'} 219 313 </button> 220 314 ` : ''} 315 + 316 + <input 317 + type="file" 318 + id="avatar-input" 319 + accept="image/png,image/jpeg" 320 + @change=${this.#handleAvatarFileChange} 321 + /> 221 322 222 323 <grain-toast></grain-toast> 223 324 `;
+33 -1
src/components/pages/grain-profile.js
··· 5 5 import '../organisms/grain-profile-header.js'; 6 6 import '../organisms/grain-gallery-grid.js'; 7 7 import '../molecules/grain-pull-to-refresh.js'; 8 + import '../molecules/grain-avatar-crop.js'; 8 9 import '../atoms/grain-spinner.js'; 9 10 import '../organisms/grain-action-dialog.js'; 10 11 ··· 17 18 _error: { state: true }, 18 19 _menuOpen: { state: true }, 19 20 _menuActions: { state: true }, 20 - _showAvatarFullscreen: { state: true } 21 + _showAvatarFullscreen: { state: true }, 22 + _showAvatarCrop: { state: true }, 23 + _cropImageUrl: { state: true } 21 24 }; 22 25 23 26 static styles = css` ··· 61 64 this._menuOpen = false; 62 65 this._menuActions = []; 63 66 this._showAvatarFullscreen = false; 67 + this._showAvatarCrop = false; 68 + this._cropImageUrl = null; 64 69 } 65 70 66 71 #handleMenuOpen(e) { ··· 86 91 this._showAvatarFullscreen = false; 87 92 } 88 93 94 + #handleAvatarCropStart(e) { 95 + this._cropImageUrl = e.detail.imageUrl; 96 + this._showAvatarCrop = true; 97 + } 98 + 99 + #handleCropCancel() { 100 + this._showAvatarCrop = false; 101 + this._cropImageUrl = null; 102 + } 103 + 104 + async #handleCrop(e) { 105 + this._showAvatarCrop = false; 106 + this._cropImageUrl = null; 107 + // Call the profile header's upload method 108 + const header = this.shadowRoot.querySelector('grain-profile-header'); 109 + await header?.uploadCroppedAvatar(e.detail.dataUrl); 110 + } 111 + 89 112 connectedCallback() { 90 113 super.connectedCallback(); 91 114 // Check cache first to avoid flash ··· 139 162 .profile=${this._profile} 140 163 @menu-open=${this.#handleMenuOpen} 141 164 @avatar-click=${this.#handleAvatarClick} 165 + @avatar-crop-start=${this.#handleAvatarCropStart} 166 + @avatar-updated=${this.#handleRefresh} 142 167 ></grain-profile-header> 143 168 144 169 ${this._profile.galleries.length > 0 ? html` ··· 165 190 @action=${this.#handleMenuAction} 166 191 @close=${this.#handleMenuClose} 167 192 ></grain-action-dialog> 193 + 194 + <grain-avatar-crop 195 + ?open=${this._showAvatarCrop} 196 + image-url=${this._cropImageUrl || ''} 197 + @crop=${this.#handleCrop} 198 + @cancel=${this.#handleCropCancel} 199 + ></grain-avatar-crop> 168 200 `; 169 201 } 170 202 }
+2
src/services/grain-api.js
··· 258 258 actorHandle 259 259 displayName 260 260 description 261 + createdAt 261 262 avatar { url(preset: "avatar") } 262 263 socialGrainGraphFollowByDid { 263 264 totalCount ··· 370 371 handle: profile?.actorHandle || handle, 371 372 displayName: profile?.displayName || '', 372 373 description: profile?.description || '', 374 + createdAt: profile?.createdAt || null, 373 375 avatarUrl: profile?.avatar?.url || '', 374 376 did: profile?.did || '', 375 377 galleryCount: galleriesConnection?.totalCount || 0,
+52
src/services/mutations.js
··· 134 134 } 135 135 `, { rkey }); 136 136 } 137 + 138 + async uploadBlob(base64Data, mimeType = 'image/jpeg') { 139 + const client = auth.getClient(); 140 + const result = await client.mutate(` 141 + mutation UploadBlob($data: String!, $mimeType: String!) { 142 + uploadBlob(data: $data, mimeType: $mimeType) { 143 + ref 144 + mimeType 145 + size 146 + } 147 + } 148 + `, { data: base64Data, mimeType }); 149 + 150 + return result.uploadBlob; 151 + } 152 + 153 + async updateAvatar(dataUrl, profile) { 154 + const client = auth.getClient(); 155 + 156 + // Upload the blob (already resized by crop component) 157 + const base64Data = dataUrl.split(',')[1]; 158 + const blob = await this.uploadBlob(base64Data, 'image/jpeg'); 159 + 160 + if (!blob) { 161 + throw new Error('Failed to upload avatar'); 162 + } 163 + 164 + // Build input with all profile fields (update requires full object) 165 + const input = { 166 + displayName: profile.displayName || null, 167 + description: profile.description || null, 168 + createdAt: profile.createdAt || new Date().toISOString(), 169 + avatar: { 170 + $type: 'blob', 171 + ref: { $link: blob.ref }, 172 + mimeType: blob.mimeType, 173 + size: blob.size 174 + } 175 + }; 176 + 177 + // Update profile 178 + await client.mutate(` 179 + mutation UpdateProfile($rkey: String!, $input: SocialGrainActorProfileInput!) { 180 + updateSocialGrainActorProfile(rkey: $rkey, input: $input) { 181 + uri 182 + } 183 + } 184 + `, { rkey: 'self', input }); 185 + 186 + // Refresh user data 187 + await auth.refreshUser(); 188 + } 137 189 } 138 190 139 191 export const mutations = new MutationsService();