Tend your corner of the atmosphere. spores.garden turns your AT Protocol records into a personal site with unique themes. Your data never leaves your PDS. Grow something that's truly yours. spores.garden
at main 541 lines 17 kB view raw
1/** 2 * Modal for creating and editing profile records. 3 * Supports display name, pronouns, bio, avatar, and banner images with blob upload. 4 */ 5 6import { putRecord, uploadBlob } from '../oauth'; 7import { addSection, updateSection, getSiteOwnerDid } from '../config'; 8import { getRecord } from '../at-client'; 9import { getCollection } from '../config/nsid'; 10 11// Type for blob reference as stored in AT Proto records 12interface BlobRef { 13 $type: 'blob'; 14 ref: { 15 $link: string; 16 }; 17 mimeType: string; 18 size: number; 19} 20 21class CreateProfile extends HTMLElement { 22 private onClose: (() => void) | null = null; 23 private displayName: string = ''; 24 private pronouns: string = ''; 25 private description: string = ''; 26 private avatarFile: File | null = null; 27 private bannerFile: File | null = null; 28 private existingAvatar: BlobRef | null = null; 29 private existingBanner: BlobRef | null = null; 30 private editMode: boolean = false; 31 private editRkey: string | null = null; 32 private editSectionId: string | null = null; 33 34 connectedCallback() { 35 this.render(); 36 } 37 38 setOnClose(callback: () => void) { 39 this.onClose = callback; 40 } 41 42 show() { 43 this.style.display = 'flex'; 44 this.render(); 45 } 46 47 editProfile(profileData: { 48 rkey?: string; 49 sectionId?: string; 50 displayName?: string; 51 pronouns?: string; 52 description?: string; 53 avatar?: BlobRef; 54 banner?: BlobRef; 55 }) { 56 this.editMode = true; 57 this.editRkey = profileData.rkey || null; 58 this.editSectionId = profileData.sectionId || null; 59 this.displayName = profileData.displayName || ''; 60 this.pronouns = profileData.pronouns || ''; 61 this.description = profileData.description || ''; 62 this.existingAvatar = profileData.avatar || null; 63 this.existingBanner = profileData.banner || null; 64 this.avatarFile = null; 65 this.bannerFile = null; 66 this.show(); 67 } 68 69 hide() { 70 this.style.display = 'none'; 71 } 72 73 private render() { 74 this.className = 'modal'; 75 this.style.display = 'flex'; 76 77 const avatarPreview = this.avatarFile 78 ? `<div class="selected-file">New: ${this.avatarFile.name}</div>` 79 : this.existingAvatar 80 ? `<div class="selected-file existing">Current avatar set</div>` 81 : ''; 82 83 const bannerPreview = this.bannerFile 84 ? `<div class="selected-file">New: ${this.bannerFile.name}</div>` 85 : this.existingBanner 86 ? `<div class="selected-file existing">Current banner set</div>` 87 : ''; 88 89 this.innerHTML = ` 90 <div class="modal-content create-profile-modal"> 91 <h2>${this.editMode ? 'Edit Profile' : 'Create Profile'}</h2> 92 93 <div class="form-group"> 94 <label for="profile-display-name">Display Name</label> 95 <input type="text" id="profile-display-name" class="input" placeholder="Your name" maxlength="200" value="${(this.displayName || '').replace(/"/g, '&quot;')}"> 96 </div> 97 98 <div class="form-group"> 99 <label for="profile-pronouns">Pronouns (optional)</label> 100 <input type="text" id="profile-pronouns" class="input" placeholder="they/them" maxlength="100" value="${(this.pronouns || '').replace(/"/g, '&quot;')}"> 101 </div> 102 103 <div class="form-group"> 104 <label for="profile-description">Description / Bio</label> 105 <textarea id="profile-description" class="textarea" rows="6" placeholder="Tell us about yourself...">${(this.description || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</textarea> 106 </div> 107 108 <div class="form-group"> 109 <label>Avatar Image</label> 110 <div class="drop-zone" id="avatar-drop-zone"> 111 <div class="drop-zone-content"> 112 <span class="icon">👤</span> 113 <p>Drag & drop avatar image</p> 114 <p class="sub-text">or click to select (max 1MB)</p> 115 ${avatarPreview} 116 </div> 117 <input type="file" id="avatar-input" class="file-input" accept="image/png,image/jpeg,image/webp,image/gif" style="display: none;"> 118 </div> 119 ${this.existingAvatar || this.avatarFile ? `<button class="button button-small button-secondary clear-avatar-btn" style="margin-top: var(--spacing-sm);">Clear Avatar</button>` : ''} 120 </div> 121 122 <div class="form-group"> 123 <label>Banner Image (optional)</label> 124 <div class="drop-zone" id="banner-drop-zone"> 125 <div class="drop-zone-content"> 126 <span class="icon">🖼️</span> 127 <p>Drag & drop banner image</p> 128 <p class="sub-text">or click to select (max 2MB)</p> 129 ${bannerPreview} 130 </div> 131 <input type="file" id="banner-input" class="file-input" accept="image/png,image/jpeg,image/webp,image/gif" style="display: none;"> 132 </div> 133 ${this.existingBanner || this.bannerFile ? `<button class="button button-small button-secondary clear-banner-btn" style="margin-top: var(--spacing-sm);">Clear Banner</button>` : ''} 134 </div> 135 136 <div class="modal-actions"> 137 <button class="button button-primary" id="create-profile-btn">${this.editMode ? 'Save Changes' : 'Create Profile'}</button> 138 <button class="button button-secondary modal-close">Cancel</button> 139 </div> 140 </div> 141 `; 142 143 // Add styles for drop zone 144 const style = document.createElement('style'); 145 style.textContent = ` 146 .create-profile-modal .drop-zone { 147 border: 2px dashed var(--border-color); 148 border-radius: var(--radius-md); 149 padding: var(--spacing-lg); 150 text-align: center; 151 cursor: pointer; 152 transition: all 0.2s ease; 153 background: var(--bg-color-alt); 154 } 155 .create-profile-modal .drop-zone:hover, 156 .create-profile-modal .drop-zone.drag-over { 157 border-color: var(--primary-color); 158 background: var(--bg-color); 159 } 160 .create-profile-modal .drop-zone-content { 161 pointer-events: none; 162 } 163 .create-profile-modal .drop-zone .icon { 164 font-size: 32px; 165 display: block; 166 margin-bottom: var(--spacing-sm); 167 } 168 .create-profile-modal .drop-zone .sub-text { 169 font-size: 0.85em; 170 opacity: 0.7; 171 margin-top: var(--spacing-xs); 172 } 173 .create-profile-modal .selected-file { 174 margin-top: var(--spacing-sm); 175 font-weight: bold; 176 color: var(--primary-color); 177 font-size: 0.9em; 178 } 179 .create-profile-modal .selected-file.existing { 180 color: var(--text-color-muted); 181 } 182 .create-profile-modal .button-small { 183 padding: var(--spacing-xs) var(--spacing-sm); 184 font-size: 0.85em; 185 } 186 `; 187 this.appendChild(style); 188 189 this.attachEventListeners(); 190 } 191 192 private attachEventListeners() { 193 const displayNameInput = this.querySelector('#profile-display-name') as HTMLInputElement; 194 const pronounsInput = this.querySelector('#profile-pronouns') as HTMLInputElement; 195 const descriptionTextarea = this.querySelector('#profile-description') as HTMLTextAreaElement; 196 const avatarDropZone = this.querySelector('#avatar-drop-zone') as HTMLDivElement; 197 const avatarInput = this.querySelector('#avatar-input') as HTMLInputElement; 198 const bannerDropZone = this.querySelector('#banner-drop-zone') as HTMLDivElement; 199 const bannerInput = this.querySelector('#banner-input') as HTMLInputElement; 200 const createBtn = this.querySelector('#create-profile-btn') as HTMLButtonElement; 201 const cancelBtn = this.querySelector('.modal-close') as HTMLButtonElement; 202 const clearAvatarBtn = this.querySelector('.clear-avatar-btn') as HTMLButtonElement; 203 const clearBannerBtn = this.querySelector('.clear-banner-btn') as HTMLButtonElement; 204 205 // Handle display name input 206 displayNameInput?.addEventListener('input', (e) => { 207 this.displayName = (e.target as HTMLInputElement).value.trim(); 208 }); 209 210 // Handle pronouns input 211 pronounsInput?.addEventListener('input', (e) => { 212 this.pronouns = (e.target as HTMLInputElement).value.trim(); 213 }); 214 215 // Handle description input 216 descriptionTextarea?.addEventListener('input', (e) => { 217 this.description = (e.target as HTMLTextAreaElement).value; 218 }); 219 220 // Avatar drop zone handlers 221 this.setupDropZone(avatarDropZone, avatarInput, 'avatar', 1000000); 222 223 // Banner drop zone handlers 224 this.setupDropZone(bannerDropZone, bannerInput, 'banner', 2000000); 225 226 // Clear buttons 227 clearAvatarBtn?.addEventListener('click', (e) => { 228 e.preventDefault(); 229 this.avatarFile = null; 230 this.existingAvatar = null; 231 this.render(); 232 }); 233 234 clearBannerBtn?.addEventListener('click', (e) => { 235 e.preventDefault(); 236 this.bannerFile = null; 237 this.existingBanner = null; 238 this.render(); 239 }); 240 241 // Handle create/save button 242 createBtn?.addEventListener('click', async () => { 243 if (createBtn) { 244 createBtn.disabled = true; 245 createBtn.textContent = this.editMode ? 'Saving...' : 'Creating...'; 246 } 247 248 try { 249 if (this.editMode) { 250 await this.updateProfileRecord(); 251 } else { 252 await this.createProfileRecord(); 253 } 254 255 this.close(); 256 } catch (error) { 257 console.error(`Failed to ${this.editMode ? 'update' : 'create'} profile:`, error); 258 alert(`Failed to ${this.editMode ? 'update' : 'create'} profile: ${error instanceof Error ? error.message : 'Unknown error'}`); 259 if (createBtn) { 260 createBtn.disabled = false; 261 createBtn.textContent = this.editMode ? 'Save Changes' : 'Create Profile'; 262 } 263 } 264 }); 265 266 // Handle cancel button 267 cancelBtn?.addEventListener('click', () => this.close()); 268 269 // Handle backdrop click 270 this.addEventListener('click', (e) => { 271 if (e.target === this) { 272 this.close(); 273 } 274 }); 275 } 276 277 private setupDropZone( 278 dropZone: HTMLDivElement | null, 279 fileInput: HTMLInputElement | null, 280 type: 'avatar' | 'banner', 281 maxSize: number 282 ) { 283 if (!dropZone || !fileInput) return; 284 285 dropZone.addEventListener('click', () => { 286 fileInput.click(); 287 }); 288 289 dropZone.addEventListener('dragover', (e) => { 290 e.preventDefault(); 291 dropZone.classList.add('drag-over'); 292 }); 293 294 dropZone.addEventListener('dragleave', () => { 295 dropZone.classList.remove('drag-over'); 296 }); 297 298 dropZone.addEventListener('drop', (e) => { 299 e.preventDefault(); 300 dropZone.classList.remove('drag-over'); 301 302 if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { 303 const file = e.dataTransfer.files[0]; 304 this.handleFileSelection(file, type, maxSize); 305 } 306 }); 307 308 fileInput.addEventListener('change', (e) => { 309 const files = (e.target as HTMLInputElement).files; 310 if (files && files.length > 0) { 311 this.handleFileSelection(files[0], type, maxSize); 312 } 313 }); 314 } 315 316 private handleFileSelection(file: File, type: 'avatar' | 'banner', maxSize: number) { 317 if (!file.type.startsWith('image/')) { 318 alert('Please select an image file.'); 319 return; 320 } 321 322 const acceptedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']; 323 if (!acceptedTypes.includes(file.type)) { 324 alert('Please select a PNG, JPEG, WebP, or GIF image.'); 325 return; 326 } 327 328 if (file.size > maxSize) { 329 const maxMB = maxSize / 1000000; 330 alert(`File is too large. Maximum size is ${maxMB}MB.`); 331 return; 332 } 333 334 if (type === 'avatar') { 335 this.avatarFile = file; 336 this.existingAvatar = null; 337 } else { 338 this.bannerFile = file; 339 this.existingBanner = null; 340 } 341 342 this.render(); 343 } 344 345 private async uploadImageBlob(file: File): Promise<BlobRef> { 346 const uploadResult = await uploadBlob(file, file.type); 347 const blobRef = uploadResult.data?.blob; 348 349 if (!blobRef) { 350 throw new Error('Upload successful but no blob reference returned'); 351 } 352 353 return blobRef; 354 } 355 356 private async createProfileRecord() { 357 const ownerDid = getSiteOwnerDid(); 358 if (!ownerDid) { 359 throw new Error('Not logged in'); 360 } 361 const profileCollection = getCollection('siteProfile'); 362 363 // Create the profile record 364 const record: any = { 365 $type: profileCollection, 366 createdAt: new Date().toISOString(), 367 updatedAt: new Date().toISOString() 368 }; 369 370 if (this.displayName) { 371 record.displayName = this.displayName; 372 } 373 if (this.pronouns) { 374 record.pronouns = this.pronouns; 375 } 376 if (this.description) { 377 record.description = this.description; 378 } 379 380 // Upload avatar if selected 381 if (this.avatarFile) { 382 record.avatar = await this.uploadImageBlob(this.avatarFile); 383 } 384 385 // Upload banner if selected 386 if (this.bannerFile) { 387 record.banner = await this.uploadImageBlob(this.bannerFile); 388 } 389 390 // Use 'self' as a consistent rkey for the user's profile 391 await putRecord(profileCollection, 'self', record); 392 393 // Add section to config referencing this profile 394 const section: any = { 395 type: 'profile', 396 collection: profileCollection, 397 rkey: 'self' 398 }; 399 400 if (this.displayName) { 401 section.title = this.displayName; 402 } 403 404 addSection(section); 405 406 // Trigger re-render 407 window.dispatchEvent(new CustomEvent('config-updated')); 408 } 409 410 private async updateProfileRecord() { 411 const ownerDid = getSiteOwnerDid(); 412 if (!ownerDid) { 413 throw new Error('Not logged in'); 414 } 415 const profileCollection = getCollection('siteProfile'); 416 417 if (this.editRkey) { 418 // Load existing record first to merge all fields 419 let existingRecord: any = {}; 420 try { 421 const existing = await getRecord(ownerDid, profileCollection, this.editRkey); 422 if (existing?.value) { 423 existingRecord = existing.value; 424 } 425 } catch (error) { 426 console.warn('Could not load existing record, creating new one:', error); 427 } 428 429 // Start with existing record and update only changed fields 430 const record: any = { 431 ...existingRecord, 432 $type: profileCollection, 433 updatedAt: new Date().toISOString() 434 }; 435 436 // Update displayName (even if empty string, as user may have cleared it) 437 record.displayName = this.displayName; 438 439 // Update description (even if empty string, as user may have cleared it) 440 record.description = this.description; 441 record.pronouns = this.pronouns; 442 443 // Handle avatar: new file, existing blob, or cleared 444 if (this.avatarFile) { 445 record.avatar = await this.uploadImageBlob(this.avatarFile); 446 } else if (this.existingAvatar) { 447 record.avatar = this.existingAvatar; 448 } else { 449 // Avatar was explicitly cleared 450 delete record.avatar; 451 } 452 453 // Handle banner: new file, existing blob, or cleared 454 if (this.bannerFile) { 455 record.banner = await this.uploadImageBlob(this.bannerFile); 456 } else if (this.existingBanner) { 457 record.banner = this.existingBanner; 458 } else { 459 // Banner was explicitly cleared 460 delete record.banner; 461 } 462 463 // Preserve createdAt if it exists 464 if (existingRecord.createdAt) { 465 record.createdAt = existingRecord.createdAt; 466 } 467 468 await putRecord(profileCollection, this.editRkey, record); 469 470 // Update section config if displayName changed 471 if (this.editSectionId && this.displayName) { 472 updateSection(this.editSectionId, { title: this.displayName }); 473 } 474 } else { 475 // No rkey exists - create a new profile record and update section 476 const record: any = { 477 $type: profileCollection, 478 createdAt: new Date().toISOString(), 479 updatedAt: new Date().toISOString() 480 }; 481 482 if (this.displayName) { 483 record.displayName = this.displayName; 484 } 485 if (this.pronouns) { 486 record.pronouns = this.pronouns; 487 } 488 if (this.description) { 489 record.description = this.description; 490 } 491 492 // Upload avatar if selected 493 if (this.avatarFile) { 494 record.avatar = await this.uploadImageBlob(this.avatarFile); 495 } 496 497 // Upload banner if selected 498 if (this.bannerFile) { 499 record.banner = await this.uploadImageBlob(this.bannerFile); 500 } 501 502 // Use 'self' as a consistent rkey for the user's profile 503 await putRecord(profileCollection, 'self', record); 504 505 // Update the section to reference the profile record 506 if (this.editSectionId) { 507 const updates: any = { 508 collection: profileCollection, 509 rkey: 'self' 510 }; 511 if (this.displayName) { 512 updates.title = this.displayName; 513 } 514 updateSection(this.editSectionId, updates); 515 } 516 } 517 518 // Trigger re-render 519 window.dispatchEvent(new CustomEvent('config-updated')); 520 } 521 522 private close() { 523 this.hide(); 524 if (this.onClose) { 525 this.onClose(); 526 } 527 // Reset form state 528 this.editMode = false; 529 this.editRkey = null; 530 this.editSectionId = null; 531 this.displayName = ''; 532 this.pronouns = ''; 533 this.description = ''; 534 this.avatarFile = null; 535 this.bannerFile = null; 536 this.existingAvatar = null; 537 this.existingBanner = null; 538 } 539} 540 541customElements.define('create-profile', CreateProfile);