WIP PWA for Grain

feat: add avatar crop to edit profile page

Integrate grain-avatar-crop component into edit profile page for
consistent avatar selection experience across the app.

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

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

+32 -6
+32 -6
src/components/pages/grain-edit-profile.js
··· 9 9 import '../atoms/grain-textarea.js'; 10 10 import '../atoms/grain-avatar.js'; 11 11 import '../molecules/grain-form-field.js'; 12 + import '../molecules/grain-avatar-crop.js'; 12 13 13 14 const UPLOAD_BLOB_MUTATION = ` 14 15 mutation UploadBlob($data: String!, $mimeType: String!) { ··· 38 39 _description: { state: true }, 39 40 _avatarUrl: { state: true }, 40 41 _newAvatarDataUrl: { state: true }, 41 - _avatarRemoved: { state: true } 42 + _avatarRemoved: { state: true }, 43 + _showAvatarCrop: { state: true }, 44 + _cropImageUrl: { state: true } 42 45 }; 43 46 44 47 static styles = css` ··· 166 169 this._avatarUrl = ''; 167 170 this._newAvatarDataUrl = null; 168 171 this._avatarRemoved = false; 172 + this._showAvatarCrop = false; 173 + this._cropImageUrl = null; 169 174 } 170 175 171 176 async connectedCallback() { ··· 220 225 } 221 226 222 227 async #handleAvatarChange(e) { 223 - const file = e.target.files?.[0]; 228 + const input = e.target; 229 + const file = input.files?.[0]; 224 230 if (!file) return; 231 + 232 + // Reset file input immediately so same file can be selected again 233 + input.value = ''; 225 234 226 235 try { 227 236 const dataUrl = await readFileAsDataURL(file); ··· 230 239 height: 2000, 231 240 maxSize: 900000 232 241 }); 233 - this._newAvatarDataUrl = resized.dataUrl; 234 - this._avatarRemoved = false; 242 + // Show crop modal instead of setting directly 243 + this._cropImageUrl = resized.dataUrl; 244 + this._showAvatarCrop = true; 235 245 } catch (err) { 236 246 console.error('Failed to process avatar:', err); 237 247 this._error = 'Failed to process image'; 238 248 } 249 + } 239 250 240 - // Reset file input 241 - e.target.value = ''; 251 + #handleCropCancel() { 252 + this._showAvatarCrop = false; 253 + this._cropImageUrl = null; 254 + } 255 + 256 + #handleCrop(e) { 257 + this._showAvatarCrop = false; 258 + this._cropImageUrl = null; 259 + this._newAvatarDataUrl = e.detail.dataUrl; 260 + this._avatarRemoved = false; 242 261 } 243 262 244 263 #handleRemoveAvatar() { ··· 388 407 @click=${this.#handleSave} 389 408 >Save</grain-button> 390 409 </div> 410 + 411 + <grain-avatar-crop 412 + ?open=${this._showAvatarCrop} 413 + image-url=${this._cropImageUrl || ''} 414 + @crop=${this.#handleCrop} 415 + @cancel=${this.#handleCropCancel} 416 + ></grain-avatar-crop> 391 417 `; 392 418 } 393 419 }