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 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, '"')}">
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, '"')}">
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, '<').replace(/>/g, '>')}</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);