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
fork

Configure Feed

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

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);