WIP PWA for Grain
0
fork

Configure Feed

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

feat: add grain-edit-profile page component

+406
+406
src/components/pages/grain-edit-profile.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { router } from '../../router.js'; 3 + import { auth } from '../../services/auth.js'; 4 + import { grainApi } from '../../services/grain-api.js'; 5 + import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; 6 + import '../atoms/grain-icon.js'; 7 + import '../atoms/grain-button.js'; 8 + import '../atoms/grain-input.js'; 9 + import '../atoms/grain-textarea.js'; 10 + import '../atoms/grain-avatar.js'; 11 + 12 + const UPLOAD_BLOB_MUTATION = ` 13 + mutation UploadBlob($data: String!, $mimeType: String!) { 14 + uploadBlob(data: $data, mimeType: $mimeType) { 15 + ref 16 + mimeType 17 + size 18 + } 19 + } 20 + `; 21 + 22 + const UPDATE_PROFILE_MUTATION = ` 23 + mutation UpdateProfile($input: SocialGrainActorProfileInput!) { 24 + updateSocialGrainActorProfile(input: $input) { 25 + uri 26 + } 27 + } 28 + `; 29 + 30 + export class GrainEditProfile extends LitElement { 31 + static properties = { 32 + _loading: { state: true }, 33 + _saving: { state: true }, 34 + _error: { state: true }, 35 + _originalProfile: { state: true }, 36 + _displayName: { state: true }, 37 + _description: { state: true }, 38 + _avatarUrl: { state: true }, 39 + _newAvatarDataUrl: { state: true }, 40 + _avatarRemoved: { state: true } 41 + }; 42 + 43 + static styles = css` 44 + :host { 45 + display: block; 46 + max-width: var(--feed-max-width); 47 + margin: 0 auto; 48 + min-height: 100vh; 49 + min-height: 100dvh; 50 + padding-bottom: 80px; 51 + background: var(--color-bg-primary); 52 + } 53 + .header { 54 + display: flex; 55 + align-items: center; 56 + gap: var(--space-sm); 57 + padding: var(--space-md) var(--space-sm); 58 + } 59 + @media (min-width: 600px) { 60 + .header { 61 + padding-left: 0; 62 + padding-right: 0; 63 + } 64 + } 65 + .back-button { 66 + background: none; 67 + border: none; 68 + padding: 8px; 69 + cursor: pointer; 70 + color: var(--color-text-primary); 71 + margin-left: -8px; 72 + } 73 + h1 { 74 + font-size: var(--font-size-md); 75 + font-weight: var(--font-weight-semibold); 76 + color: var(--color-text-primary); 77 + margin: 0; 78 + } 79 + .content { 80 + padding: 0 var(--space-sm); 81 + } 82 + @media (min-width: 600px) { 83 + .content { 84 + padding: 0; 85 + } 86 + } 87 + .avatar-section { 88 + display: flex; 89 + flex-direction: column; 90 + align-items: center; 91 + margin-bottom: var(--space-lg); 92 + } 93 + .avatar-wrapper { 94 + position: relative; 95 + cursor: pointer; 96 + } 97 + .avatar-overlay { 98 + position: absolute; 99 + bottom: 0; 100 + right: 0; 101 + width: 28px; 102 + height: 28px; 103 + border-radius: 50%; 104 + background: var(--color-bg-primary); 105 + border: 2px solid var(--color-border); 106 + display: flex; 107 + align-items: center; 108 + justify-content: center; 109 + color: var(--color-text-primary); 110 + } 111 + .avatar-preview { 112 + width: 80px; 113 + height: 80px; 114 + border-radius: 50%; 115 + object-fit: cover; 116 + background: var(--color-bg-elevated); 117 + } 118 + .remove-avatar { 119 + background: none; 120 + border: none; 121 + color: var(--color-danger, #dc3545); 122 + font-size: var(--font-size-sm); 123 + cursor: pointer; 124 + margin-top: var(--space-xs); 125 + padding: var(--space-xs); 126 + } 127 + .remove-avatar:hover { 128 + text-decoration: underline; 129 + } 130 + input[type="file"] { 131 + display: none; 132 + } 133 + .form-group { 134 + margin-bottom: var(--space-md); 135 + } 136 + .form-group label { 137 + display: block; 138 + font-size: var(--font-size-sm); 139 + font-weight: var(--font-weight-medium); 140 + color: var(--color-text-primary); 141 + margin-bottom: var(--space-xs); 142 + } 143 + .char-count { 144 + font-size: var(--font-size-xs); 145 + color: var(--color-text-secondary); 146 + text-align: right; 147 + margin-top: 4px; 148 + } 149 + .save-section { 150 + padding: var(--space-md) var(--space-sm); 151 + border-top: 1px solid var(--color-border); 152 + margin-top: var(--space-lg); 153 + } 154 + @media (min-width: 600px) { 155 + .save-section { 156 + padding-left: 0; 157 + padding-right: 0; 158 + } 159 + } 160 + .error { 161 + color: var(--color-danger, #dc3545); 162 + font-size: var(--font-size-sm); 163 + padding: var(--space-sm); 164 + text-align: center; 165 + } 166 + .loading { 167 + display: flex; 168 + justify-content: center; 169 + padding: var(--space-xl); 170 + } 171 + `; 172 + 173 + constructor() { 174 + super(); 175 + this._loading = true; 176 + this._saving = false; 177 + this._error = null; 178 + this._originalProfile = null; 179 + this._displayName = ''; 180 + this._description = ''; 181 + this._avatarUrl = ''; 182 + this._newAvatarDataUrl = null; 183 + this._avatarRemoved = false; 184 + } 185 + 186 + async connectedCallback() { 187 + super.connectedCallback(); 188 + await this.#loadProfile(); 189 + } 190 + 191 + async #loadProfile() { 192 + try { 193 + const profile = await grainApi.getCurrentProfile(); 194 + this._originalProfile = profile; 195 + this._displayName = profile.displayName; 196 + this._description = profile.description; 197 + this._avatarUrl = profile.avatarUrl; 198 + } catch (err) { 199 + console.error('Failed to load profile:', err); 200 + this._error = 'Failed to load profile'; 201 + } finally { 202 + this._loading = false; 203 + } 204 + } 205 + 206 + #goBack() { 207 + if (this.#isDirty) { 208 + if (!confirm('You have unsaved changes. Leave anyway?')) { 209 + return; 210 + } 211 + } 212 + history.back(); 213 + } 214 + 215 + get #isDirty() { 216 + if (!this._originalProfile) return false; 217 + if (this._displayName !== this._originalProfile.displayName) return true; 218 + if (this._description !== this._originalProfile.description) return true; 219 + if (this._newAvatarDataUrl) return true; 220 + if (this._avatarRemoved && this._originalProfile.avatarUrl) return true; 221 + return false; 222 + } 223 + 224 + #handleDisplayNameChange(e) { 225 + this._displayName = e.detail.value.slice(0, 64); 226 + } 227 + 228 + #handleDescriptionChange(e) { 229 + this._description = e.detail.value.slice(0, 256); 230 + } 231 + 232 + #handleAvatarClick() { 233 + this.shadowRoot.querySelector('#avatar-input').click(); 234 + } 235 + 236 + async #handleAvatarChange(e) { 237 + const file = e.target.files?.[0]; 238 + if (!file) return; 239 + 240 + try { 241 + const dataUrl = await readFileAsDataURL(file); 242 + const resized = await resizeImage(dataUrl, { 243 + width: 2000, 244 + height: 2000, 245 + maxSize: 900000 246 + }); 247 + this._newAvatarDataUrl = resized.dataUrl; 248 + this._avatarRemoved = false; 249 + } catch (err) { 250 + console.error('Failed to process avatar:', err); 251 + this._error = 'Failed to process image'; 252 + } 253 + 254 + // Reset file input 255 + e.target.value = ''; 256 + } 257 + 258 + #handleRemoveAvatar() { 259 + this._newAvatarDataUrl = null; 260 + this._avatarRemoved = true; 261 + } 262 + 263 + get #displayedAvatarUrl() { 264 + if (this._newAvatarDataUrl) return this._newAvatarDataUrl; 265 + if (this._avatarRemoved) return ''; 266 + return this._avatarUrl; 267 + } 268 + 269 + get #showRemoveButton() { 270 + return this._newAvatarDataUrl || (!this._avatarRemoved && this._avatarUrl); 271 + } 272 + 273 + async #handleSave() { 274 + if (!this.#isDirty || this._saving) return; 275 + 276 + this._saving = true; 277 + this._error = null; 278 + 279 + try { 280 + const client = auth.getClient(); 281 + const input = { 282 + displayName: this._displayName.trim() || null, 283 + description: this._description.trim() || null 284 + }; 285 + 286 + // Handle avatar 287 + if (this._newAvatarDataUrl) { 288 + // Upload new avatar 289 + const base64Data = this._newAvatarDataUrl.split(',')[1]; 290 + const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, { 291 + data: base64Data, 292 + mimeType: 'image/jpeg' 293 + }); 294 + 295 + if (!uploadResult.uploadBlob) { 296 + throw new Error('Failed to upload avatar'); 297 + } 298 + 299 + input.avatar = { 300 + $type: 'blob', 301 + ref: { $link: uploadResult.uploadBlob.ref }, 302 + mimeType: uploadResult.uploadBlob.mimeType, 303 + size: uploadResult.uploadBlob.size 304 + }; 305 + } else if (this._avatarRemoved) { 306 + input.avatar = null; 307 + } 308 + 309 + await client.mutate(UPDATE_PROFILE_MUTATION, { input }); 310 + 311 + // Navigate back to settings 312 + router.push('/settings'); 313 + 314 + } catch (err) { 315 + console.error('Failed to save profile:', err); 316 + this._error = err.message || 'Failed to save profile'; 317 + } finally { 318 + this._saving = false; 319 + } 320 + } 321 + 322 + render() { 323 + if (this._loading) { 324 + return html` 325 + <div class="header"> 326 + <button class="back-button" @click=${this.#goBack}> 327 + <grain-icon name="back" size="20"></grain-icon> 328 + </button> 329 + <h1>Edit Profile</h1> 330 + </div> 331 + <div class="loading"> 332 + <grain-spinner></grain-spinner> 333 + </div> 334 + `; 335 + } 336 + 337 + return html` 338 + <div class="header"> 339 + <button class="back-button" @click=${this.#goBack}> 340 + <grain-icon name="back" size="20"></grain-icon> 341 + </button> 342 + <h1>Edit Profile</h1> 343 + </div> 344 + 345 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 346 + 347 + <div class="content"> 348 + <div class="avatar-section"> 349 + <div class="avatar-wrapper" @click=${this.#handleAvatarClick}> 350 + ${this.#displayedAvatarUrl ? html` 351 + <img class="avatar-preview" src=${this.#displayedAvatarUrl} alt="Profile avatar"> 352 + ` : html` 353 + <grain-avatar size="lg"></grain-avatar> 354 + `} 355 + <div class="avatar-overlay"> 356 + <grain-icon name="camera" size="14"></grain-icon> 357 + </div> 358 + </div> 359 + ${this.#showRemoveButton ? html` 360 + <button class="remove-avatar" @click=${this.#handleRemoveAvatar}> 361 + Remove 362 + </button> 363 + ` : ''} 364 + <input 365 + type="file" 366 + id="avatar-input" 367 + accept="image/png,image/jpeg" 368 + @change=${this.#handleAvatarChange} 369 + > 370 + </div> 371 + 372 + <div class="form-group"> 373 + <label>Display Name</label> 374 + <grain-input 375 + placeholder="Display name" 376 + .value=${this._displayName} 377 + @input=${this.#handleDisplayNameChange} 378 + ></grain-input> 379 + <div class="char-count">${this._displayName.length}/64</div> 380 + </div> 381 + 382 + <div class="form-group"> 383 + <label>Bio</label> 384 + <grain-textarea 385 + placeholder="Tell us about yourself" 386 + .value=${this._description} 387 + .maxlength=${256} 388 + @input=${this.#handleDescriptionChange} 389 + ></grain-textarea> 390 + </div> 391 + </div> 392 + 393 + <div class="save-section"> 394 + <grain-button 395 + variant="primary" 396 + ?disabled=${!this.#isDirty} 397 + ?loading=${this._saving} 398 + loadingText="Saving..." 399 + @click=${this.#handleSave} 400 + >Save</grain-button> 401 + </div> 402 + `; 403 + } 404 + } 405 + 406 + customElements.define('grain-edit-profile', GrainEditProfile);