fork
Configure Feed
Select the types of activity you want to include in your feed.
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.
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);