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
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, '"')}">
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);