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 507 lines 17 kB view raw
1/** 2 * Modal for uploading images and creating image records. 3 * Supports drag & drop and file selection. 4 */ 5 6import { createRecord, putRecord, uploadBlob, getCurrentDid } from '../oauth'; 7import { addSection, getSiteOwnerDid, updateSection } from '../config'; 8import { getCollection } from '../config/nsid'; 9import { clearCache } from '../records/loader'; 10import { setCachedActivity } from './recent-gardens'; 11 12class CreateImage extends HTMLElement { 13 private onClose: (() => void) | null = null; 14 private imageTitle: string = ''; 15 private selectedFile: File | null = null; 16 private selectedFileUrl: string | null = null; 17 private editMode: boolean = false; 18 private editRkey: string | null = null; 19 private editSectionId: string | null = null; 20 private existingImageUrl: string | null = null; 21 private existingImageBlob: any | null = null; 22 private existingCreatedAt: string | null = null; 23 private imageCleared: boolean = false; 24 25 private async getImageDimensions(file: File): Promise<{ width: number; height: number } | null> { 26 return new Promise((resolve) => { 27 const img = new Image(); 28 const url = URL.createObjectURL(file); 29 img.onload = () => { 30 const width = img.naturalWidth; 31 const height = img.naturalHeight; 32 URL.revokeObjectURL(url); 33 resolve(width > 0 && height > 0 ? { width, height } : null); 34 }; 35 img.onerror = () => { 36 URL.revokeObjectURL(url); 37 resolve(null); 38 }; 39 img.src = url; 40 }); 41 } 42 43 private isHeicMime(mimeType: string): boolean { 44 const mime = (mimeType || '').toLowerCase(); 45 return mime === 'image/heic' || mime === 'image/heif'; 46 } 47 48 private async normalizeUploadFile(file: File): Promise<{ file: File; width?: number; height?: number }> { 49 const originalDims = await this.getImageDimensions(file); 50 51 if (!this.isHeicMime(file.type)) { 52 if (!originalDims) return { file }; 53 return { file, width: originalDims.width, height: originalDims.height }; 54 } 55 56 if (!originalDims) { 57 throw new Error('HEIC/HEIF image could not be decoded by this browser. Please convert it to JPEG or WebP and try again.'); 58 } 59 60 const sourceUrl = URL.createObjectURL(file); 61 const img = new Image(); 62 await new Promise<void>((resolve, reject) => { 63 img.onload = () => resolve(); 64 img.onerror = () => reject(new Error('Failed to decode HEIC/HEIF image.')); 65 img.src = sourceUrl; 66 }); 67 68 const canvas = document.createElement('canvas'); 69 canvas.width = img.naturalWidth || originalDims.width; 70 canvas.height = img.naturalHeight || originalDims.height; 71 const ctx = canvas.getContext('2d'); 72 if (!ctx) { 73 URL.revokeObjectURL(sourceUrl); 74 throw new Error('Image conversion is not supported in this browser.'); 75 } 76 ctx.drawImage(img, 0, 0, canvas.width, canvas.height); 77 URL.revokeObjectURL(sourceUrl); 78 79 const convertedBlob = await new Promise<Blob | null>((resolve) => { 80 canvas.toBlob((blob) => resolve(blob), 'image/webp', 0.92); 81 }); 82 if (!convertedBlob) { 83 throw new Error('Failed to convert HEIC/HEIF image to WebP.'); 84 } 85 86 const webpName = file.name.replace(/\.[^.]+$/, '') + '.webp'; 87 const convertedFile = new File([convertedBlob], webpName, { 88 type: 'image/webp', 89 lastModified: Date.now(), 90 }); 91 92 return { file: convertedFile, width: canvas.width, height: canvas.height }; 93 } 94 95 connectedCallback() { 96 this.render(); 97 } 98 99 setOnClose(callback: () => void) { 100 this.onClose = callback; 101 } 102 103 show() { 104 this.style.display = 'flex'; 105 this.render(); 106 } 107 108 editImage(imageData: { 109 rkey: string; 110 sectionId?: string; 111 title?: string; 112 imageUrl?: string | null; 113 imageBlob?: any | null; 114 createdAt?: string | null; 115 }) { 116 this.editMode = true; 117 this.editRkey = imageData.rkey; 118 this.editSectionId = imageData.sectionId || null; 119 this.imageTitle = imageData.title || ''; 120 this.existingImageUrl = imageData.imageUrl || null; 121 this.existingImageBlob = imageData.imageBlob || null; 122 this.existingCreatedAt = imageData.createdAt || null; 123 this.imageCleared = false; 124 this.show(); 125 } 126 127 hide() { 128 this.style.display = 'none'; 129 } 130 131 private render() { 132 this.className = 'modal'; 133 this.style.display = 'flex'; 134 const canSave = this.editMode 135 ? !!(this.selectedFile || (!this.imageCleared && this.existingImageBlob)) 136 : !!this.selectedFile; 137 138 const currentPreview = this.selectedFileUrl || this.existingImageUrl; 139 140 this.innerHTML = ` 141 <div class="modal-content create-image-modal"> 142 <h2>${this.editMode ? 'Edit Image' : 'Add Image'}</h2> 143 144 ${this.editMode ? ` 145 <div class="form-group"> 146 <label>Current Image</label> 147 <div class="image-preview"> 148 ${currentPreview && !this.imageCleared 149 ? `<img src="${currentPreview}" alt="Current image" />` 150 : '<div class="image-preview-empty">No image selected</div>'} 151 </div> 152 <div class="image-preview-actions"> 153 <button class="button button-secondary button-small" id="clear-image-btn" ${!currentPreview || this.imageCleared ? 'disabled' : ''}>Clear Image</button> 154 </div> 155 </div> 156 ` : ''} 157 158 <div class="form-group"> 159 <label for="image-title">Title (optional)</label> 160 <input type="text" id="image-title" class="input" placeholder="Image title" maxlength="200" value="${(this.imageTitle || '').replace(/"/g, '&quot;')}"> 161 </div> 162 163 <div class="form-group"> 164 <label>Image File</label> 165 <div class="drop-zone" id="drop-zone"> 166 <div class="drop-zone-content"> 167 <span class="icon">🖼️</span> 168 <p>Drag & drop an image here</p> 169 <p class="sub-text">or click to select</p> 170 ${this.selectedFile ? `<div class="selected-file">Selected: ${this.selectedFile.name}</div>` : ''} 171 </div> 172 <input type="file" id="image-input" class="file-input" accept="image/*" style="display: none;"> 173 </div> 174 </div> 175 176 <div class="modal-actions"> 177 <button class="button button-primary" id="create-image-btn" ${!canSave ? 'disabled' : ''}>${this.editMode ? 'Save Changes' : 'Upload & Add'}</button> 178 <button class="button button-secondary modal-close">Cancel</button> 179 </div> 180 </div> 181 `; 182 183 // Add styles for drop zone 184 const style = document.createElement('style'); 185 style.textContent = ` 186 .drop-zone { 187 border: 2px dashed var(--border-color); 188 border-radius: var(--radius-md); 189 padding: var(--spacing-xl); 190 text-align: center; 191 cursor: pointer; 192 transition: all 0.2s ease; 193 background: var(--bg-color-alt); 194 } 195 .drop-zone:hover, .drop-zone.drag-over { 196 border-color: var(--primary-color); 197 background: var(--bg-color); 198 } 199 .drop-zone-content { 200 pointer-events: none; 201 } 202 .drop-zone .icon { 203 font-size: 48px; 204 display: block; 205 margin-bottom: var(--spacing-md); 206 } 207 .drop-zone .sub-text { 208 font-size: 0.9em; 209 opacity: 0.7; 210 } 211 .selected-file { 212 margin-top: var(--spacing-sm); 213 font-weight: bold; 214 color: var(--primary-color); 215 } 216 .image-preview { 217 border: 1px solid var(--border-color); 218 border-radius: var(--radius-md); 219 padding: var(--spacing-md); 220 background: var(--bg-color-alt); 221 text-align: center; 222 } 223 .image-preview img { 224 max-width: 100%; 225 max-height: 240px; 226 border-radius: var(--radius-sm); 227 display: block; 228 margin: 0 auto; 229 } 230 .image-preview-empty { 231 color: var(--text-muted); 232 font-size: 0.9em; 233 } 234 .image-preview-actions { 235 margin-top: var(--spacing-sm); 236 display: flex; 237 gap: var(--spacing-sm); 238 } 239 `; 240 this.appendChild(style); 241 242 this.attachEventListeners(); 243 } 244 245 private attachEventListeners() { 246 const titleInput = this.querySelector('#image-title') as HTMLInputElement; 247 const dropZone = this.querySelector('#drop-zone') as HTMLDivElement; 248 const fileInput = this.querySelector('#image-input') as HTMLInputElement; 249 const createBtn = this.querySelector('#create-image-btn') as HTMLButtonElement; 250 const cancelBtn = this.querySelector('.modal-close') as HTMLButtonElement; 251 const clearBtn = this.querySelector('#clear-image-btn') as HTMLButtonElement | null; 252 253 // Handle title input 254 titleInput?.addEventListener('input', (e) => { 255 this.imageTitle = (e.target as HTMLInputElement).value.trim(); 256 }); 257 258 // Handle Drop Zone 259 dropZone?.addEventListener('click', () => { 260 fileInput.click(); 261 }); 262 263 dropZone?.addEventListener('dragover', (e) => { 264 e.preventDefault(); 265 dropZone.classList.add('drag-over'); 266 }); 267 268 dropZone?.addEventListener('dragleave', () => { 269 dropZone.classList.remove('drag-over'); 270 }); 271 272 dropZone?.addEventListener('drop', (e) => { 273 e.preventDefault(); 274 dropZone.classList.remove('drag-over'); 275 276 if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { 277 const file = e.dataTransfer.files[0]; 278 if (file.type.startsWith('image/')) { 279 this.handleFileSelection(file); 280 } else { 281 alert('Please select an image file.'); 282 } 283 } 284 }); 285 286 fileInput?.addEventListener('change', (e) => { 287 const files = (e.target as HTMLInputElement).files; 288 if (files && files.length > 0) { 289 this.handleFileSelection(files[0]); 290 } 291 }); 292 293 // Handle create button 294 createBtn?.addEventListener('click', async () => { 295 createBtn.disabled = true; 296 createBtn.textContent = this.editMode ? 'Saving...' : 'Uploading...'; 297 298 try { 299 if (this.editMode) { 300 await this.updateImageRecord(); 301 } else { 302 await this.createImageRecord(); 303 } 304 this.close(); 305 } catch (error) { 306 console.error('Failed to upload image:', error); 307 alert(`Failed to ${this.editMode ? 'update' : 'upload'} image: ${error instanceof Error ? error.message : 'Unknown error'}`); 308 createBtn.disabled = false; 309 createBtn.textContent = this.editMode ? 'Save Changes' : 'Upload & Add'; 310 } 311 }); 312 313 clearBtn?.addEventListener('click', () => { 314 this.imageCleared = true; 315 this.existingImageUrl = null; 316 this.existingImageBlob = null; 317 this.render(); 318 }); 319 320 // Handle cancel button 321 cancelBtn?.addEventListener('click', () => this.close()); 322 323 // Handle backdrop click 324 this.addEventListener('click', (e) => { 325 if (e.target === this) { 326 this.close(); 327 } 328 }); 329 } 330 331 private handleFileSelection(file: File) { 332 if (this.selectedFileUrl) { 333 URL.revokeObjectURL(this.selectedFileUrl); 334 } 335 this.selectedFile = file; 336 this.selectedFileUrl = URL.createObjectURL(file); 337 this.imageCleared = false; 338 // Re-render to show selected file 339 this.render(); 340 } 341 342 private async createImageRecord() { 343 if (!this.selectedFile) return; 344 const imageCollection = getCollection('contentImage'); 345 346 const ownerDid = getSiteOwnerDid(); 347 if (!ownerDid) { 348 throw new Error('Not logged in'); 349 } 350 351 // 1. Normalize image (HEIC/HEIF -> WebP), then upload blob 352 const normalized = await this.normalizeUploadFile(this.selectedFile); 353 const uploadResult = await uploadBlob(normalized.file, normalized.file.type); 354 355 // Handle different response structures from atcute/api vs our wrapper 356 const blobRef = uploadResult.data?.blob; 357 358 if (!blobRef) { 359 throw new Error('Upload successful but no blob reference returned'); 360 } 361 362 // 2. Create Record 363 const record: any = { 364 $type: imageCollection, 365 image: blobRef, 366 createdAt: new Date().toISOString(), 367 embed: { 368 $type: 'app.bsky.embed.images', 369 images: [ 370 { 371 alt: this.imageTitle || 'Garden image', 372 image: blobRef, 373 ...(normalized.width && normalized.height 374 ? { 375 aspectRatio: { 376 width: normalized.width, 377 height: normalized.height, 378 }, 379 } 380 : {}), 381 }, 382 ], 383 }, 384 }; 385 386 if (this.imageTitle) { 387 record.title = this.imageTitle; 388 } 389 390 const response = await createRecord(imageCollection, record) as any; 391 392 // Extract rkey 393 const rkey = response.uri.split('/').pop(); 394 395 // 3. Add Section 396 const section: any = { 397 type: 'records', 398 layout: 'image', 399 title: this.imageTitle || 'Image', 400 records: [response.uri], 401 ref: response.uri, 402 collection: imageCollection, 403 rkey 404 }; 405 406 addSection(section); 407 408 // Record local activity 409 const currentDid = getCurrentDid(); 410 if (currentDid) { 411 setCachedActivity(currentDid, 'edit', new Date()); 412 } 413 414 // Trigger re-render 415 window.dispatchEvent(new CustomEvent('config-updated')); 416 } 417 418 private async updateImageRecord() { 419 const ownerDid = getSiteOwnerDid(); 420 if (!ownerDid) { 421 throw new Error('Not logged in'); 422 } 423 424 if (!this.editRkey) { 425 throw new Error('Missing image record key'); 426 } 427 428 const imageCollection = getCollection('contentImage'); 429 let blobRef = null; 430 let aspectRatio: { width: number; height: number } | null = null; 431 if (this.selectedFile) { 432 const normalized = await this.normalizeUploadFile(this.selectedFile); 433 if (normalized.width && normalized.height) { 434 aspectRatio = { width: normalized.width, height: normalized.height }; 435 } 436 const uploadResult = await uploadBlob(normalized.file, normalized.file.type); 437 blobRef = uploadResult.data?.blob; 438 } else if (!this.imageCleared && this.existingImageBlob) { 439 blobRef = this.existingImageBlob; 440 } 441 442 if (!blobRef) { 443 throw new Error('Please select an image file.'); 444 } 445 446 const record: any = { 447 $type: imageCollection, 448 image: blobRef, 449 createdAt: this.existingCreatedAt || new Date().toISOString(), 450 embed: { 451 $type: 'app.bsky.embed.images', 452 images: [ 453 { 454 alt: this.imageTitle || 'Garden image', 455 image: blobRef, 456 ...(aspectRatio ? { aspectRatio } : {}), 457 }, 458 ], 459 }, 460 }; 461 462 if (this.imageTitle) { 463 record.title = this.imageTitle; 464 } 465 466 await putRecord(imageCollection, this.editRkey, record); 467 468 clearCache(ownerDid); 469 470 if (this.editSectionId) { 471 updateSection(this.editSectionId, { 472 title: this.imageTitle || '' 473 }); 474 } 475 476 // Record local activity 477 const currentDid = getCurrentDid(); 478 if (currentDid) { 479 setCachedActivity(currentDid, 'edit', new Date()); 480 } 481 482 window.dispatchEvent(new CustomEvent('config-updated')); 483 } 484 485 private close() { 486 this.hide(); 487 if (this.onClose) { 488 this.onClose(); 489 } 490 // Reset state 491 if (this.selectedFileUrl) { 492 URL.revokeObjectURL(this.selectedFileUrl); 493 } 494 this.selectedFileUrl = null; 495 this.imageTitle = ''; 496 this.selectedFile = null; 497 this.editMode = false; 498 this.editRkey = null; 499 this.editSectionId = null; 500 this.existingImageUrl = null; 501 this.existingImageBlob = null; 502 this.existingCreatedAt = null; 503 this.imageCleared = false; 504 } 505} 506 507customElements.define('create-image', CreateImage);