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 * Section container component.
3 * Renders sections based on type (records, content, profile).
4 * Provides edit controls for section management.
5 */
6
7import { getRecordByUri, getRecordsByUris } from '../records/loader';
8import { getSiteOwnerDid, getConfig, updateSection, removeSection, moveSectionUp, moveSectionDown, saveConfig } from '../config';
9import { getProfile, getRecord, getBlobUrl, parseAtUri } from '../at-client';
10import { deleteRecord } from '../oauth';
11import { renderRecord } from '../layouts/index';
12import { renderCollectedFlowers } from '../layouts/collected-flowers';
13import { isContentImageCollection, isContentTextCollection, isProfileCollection } from '../config/nsid';
14import { createErrorMessage, createLoadingSpinner } from '../utils/loading-states';
15import { showConfirmModal } from '../utils/confirm-modal';
16import { createHelpTooltip } from '../utils/help-tooltip';
17import { renderMarkdown } from '../utils/markdown';
18import { sanitizeHtml } from '../utils/sanitize';
19import './create-profile'; // Register profile editor component
20import './create-image'; // Register image editor component
21
22class SectionBlock extends HTMLElement {
23 section: any;
24 editMode: boolean;
25 renderToken: number;
26 constructor() {
27 super();
28 this.section = null;
29 this.editMode = false;
30 this.renderToken = 0;
31 }
32
33 static get observedAttributes() {
34 return ['data-section', 'data-edit-mode'];
35 }
36
37 attributeChangedCallback(name, _oldValue, newValue) {
38 if (name === 'data-section') {
39 try {
40 this.section = JSON.parse(newValue);
41 } catch {
42 this.section = null;
43 }
44 }
45 if (name === 'data-edit-mode') {
46 this.editMode = newValue === 'true';
47 }
48 this.render();
49 }
50
51 connectedCallback() {
52 this.render();
53 }
54
55 async render() {
56 const token = ++this.renderToken;
57 this.classList.remove('section-ready');
58 if (!this.section) {
59 this.replaceChildren();
60 this.innerHTML = '<div class="error">Invalid section</div>';
61 this.classList.add('section-ready');
62 return;
63 }
64
65 const fragment = document.createDocumentFragment();
66
67 // Check if this is a Bluesky post section (hide title in view mode)
68 const isBlueskyPostSection =
69 this.section.collection === 'app.bsky.feed.post' ||
70 (this.section.type === 'records' && this.section.records &&
71 this.section.records.some(uri => uri.includes('app.bsky.feed.post')));
72
73 // Section header (title + edit controls)
74 // Hide title if hideHeader is set, even in edit mode (so we can preview it hidden)
75 // editMode will still ensure the header container exists to hold the controls
76 const shouldShowTitle = this.section.title &&
77 (!isBlueskyPostSection && !this.section.hideHeader);
78 const isCollectedFlowers = this.section.type === 'collected-flowers';
79 const collectedFlowersHelp = isCollectedFlowers
80 ? createHelpTooltip('Collected flowers are a way to lead people to other gardens that you\u2019ve enjoyed visiting. You can collect a flower from someone\u2019s garden by visiting it and clicking \u201cPick A Flower\u201d.')
81 : null;
82
83 let editControls: HTMLElement | null = null;
84 if (shouldShowTitle || this.editMode) {
85 const header = document.createElement('div');
86 header.className = 'section-header';
87
88 // Always add a left-side container (even if empty) to keep controls on the right
89 const titleContainer = document.createElement('div');
90 titleContainer.className = 'section-title-wrapper';
91
92 if (shouldShowTitle) {
93 const title = document.createElement('h2');
94 title.className = 'section-title';
95 title.textContent = this.section.title;
96 titleContainer.appendChild(title);
97
98 // Place help tooltip inline with the title
99 if (collectedFlowersHelp) {
100 titleContainer.appendChild(collectedFlowersHelp);
101 }
102 }
103
104 header.appendChild(titleContainer);
105
106 if (this.editMode) {
107 editControls = this.createEditControls();
108 }
109
110 fragment.appendChild(header);
111 }
112
113 // Section content
114 const content = document.createElement('div');
115 content.className = 'section-content';
116
117 try {
118 switch (this.section.type) {
119 case 'profile':
120 await this.renderProfile(content);
121 break;
122 case 'records':
123 await this.renderRecords(content);
124 break;
125 case 'collected-flowers': {
126 const rendered = await renderCollectedFlowers(this.section, {
127 onRefresh: () => this.render(),
128 editMode: this.editMode,
129 });
130 rendered.classList.add('content-enter');
131 content.innerHTML = '';
132 content.appendChild(rendered);
133 break;
134 }
135 case 'content':
136 case 'block': // Support legacy 'block' type
137 await this.renderBlock(content);
138 break;
139 case 'share-to-bluesky':
140 await this.renderShareToBluesky(content);
141 break;
142 default:
143 content.innerHTML = `<p>Unknown section type: ${this.section.type}</p>`;
144 }
145 } catch (error) {
146 console.error('Failed to render section:', error);
147 const errorEl = createErrorMessage(
148 'Failed to load section',
149 async () => {
150 // Retry by re-rendering
151 await this.render();
152 },
153 error instanceof Error ? error.message : String(error)
154 );
155 content.innerHTML = '';
156 content.appendChild(errorEl);
157 }
158
159 if (token !== this.renderToken) {
160 return;
161 }
162
163 this.className = 'section';
164 this.setAttribute('data-type', this.section.type);
165 fragment.appendChild(content);
166
167 // In edit mode, keep the title above but move controls under the content
168 if (this.editMode && editControls) {
169 fragment.appendChild(editControls);
170 // Update info box asynchronously after controls are in the DOM
171 const infoBox = editControls.querySelector('.section-info');
172 if (infoBox) {
173 this.updateInfoBox(infoBox as HTMLElement);
174 }
175 }
176 this.classList.add('section-ready');
177 this.replaceChildren(fragment);
178 }
179
180 createEditControls() {
181 const controls = document.createElement('div');
182 controls.className = 'section-controls';
183
184 // Info row container (info box + move buttons)
185 const infoRow = document.createElement('div');
186 infoRow.className = 'section-info-row';
187
188 // Info box showing element type
189 const infoBox = document.createElement('div');
190 infoBox.className = 'section-info';
191 infoRow.appendChild(infoBox);
192
193 // Move buttons container
194 const moveButtons = document.createElement('div');
195 moveButtons.className = 'section-move-buttons';
196
197 // Get current section index to determine if buttons should be disabled
198 const config = getConfig();
199 const sections = config.sections || [];
200 const currentIndex = sections.findIndex(s => s.id === this.section.id);
201 const isFirst = currentIndex === 0;
202 const isLast = currentIndex === sections.length - 1;
203
204 // Move up button
205 const moveUpBtn = document.createElement('button');
206 moveUpBtn.className = 'button button-secondary button-small';
207 moveUpBtn.innerHTML = '↑';
208 moveUpBtn.title = 'Move up';
209 moveUpBtn.disabled = isFirst;
210 moveUpBtn.addEventListener('click', () => {
211 if (moveSectionUp(this.section.id)) {
212 window.dispatchEvent(new CustomEvent('config-updated'));
213 }
214 });
215 moveButtons.appendChild(moveUpBtn);
216
217 // Move down button
218 const moveDownBtn = document.createElement('button');
219 moveDownBtn.className = 'button button-secondary button-small';
220 moveDownBtn.innerHTML = '↓';
221 moveDownBtn.title = 'Move down';
222 moveDownBtn.disabled = isLast;
223 moveDownBtn.addEventListener('click', () => {
224 if (moveSectionDown(this.section.id)) {
225 window.dispatchEvent(new CustomEvent('config-updated'));
226 }
227 });
228 moveButtons.appendChild(moveDownBtn);
229
230 infoRow.appendChild(moveButtons);
231 controls.appendChild(infoRow);
232
233 // Action buttons container (toggle header, edit, delete)
234 const actionButtons = document.createElement('div');
235 actionButtons.className = 'section-action-buttons';
236
237 // Hide/Show Header toggle button (only if section has a title)
238 if (this.section.title) {
239 const toggleHeaderBtn = document.createElement('button');
240 toggleHeaderBtn.className = 'button button-secondary button-small';
241 toggleHeaderBtn.textContent = this.section.hideHeader ? 'Show Header' : 'Hide Header';
242 toggleHeaderBtn.title = this.section.hideHeader
243 ? 'Show header in preview mode'
244 : 'Hide header in preview mode';
245 toggleHeaderBtn.addEventListener('click', () => {
246 const newValue = !this.section.hideHeader;
247
248 // Update global config
249 updateSection(this.section.id, { hideHeader: newValue });
250
251 // Update local state and re-render this block immediately (avoids global flicker)
252 this.section.hideHeader = newValue;
253 this.render();
254 });
255 actionButtons.appendChild(toggleHeaderBtn);
256 }
257
258 // Edit button only for section types that support editing
259 const recordUri = this.section.records?.[0];
260 const isImageRecord =
261 isContentImageCollection(this.section.collection) ||
262 (typeof recordUri === 'string' && (recordUri.includes('/garden.spores.content.image/') || recordUri.includes('/coop.hypha.spores.content.image/')));
263
264 const supportsEditing =
265 this.section.type === 'content' ||
266 this.section.type === 'block' ||
267 this.section.type === 'profile' ||
268 (this.section.type === 'records' && isImageRecord);
269 // share-to-bluesky doesn't need editing - it's just a button
270
271 if (supportsEditing) {
272 const editBtn = document.createElement('button');
273 editBtn.className = 'button button-secondary button-small';
274 editBtn.textContent = 'Edit';
275 editBtn.addEventListener('click', () => {
276 if (this.section.type === 'content' || this.section.type === 'block') {
277 this.editBlock();
278 } else if (this.section.type === 'profile') {
279 this.editProfile();
280 } else if (this.section.type === 'records') {
281 this.editRecords();
282 }
283 });
284 actionButtons.appendChild(editBtn);
285 }
286
287 // Delete button
288 const deleteBtn = document.createElement('button');
289 deleteBtn.className = 'button button-danger button-small';
290 deleteBtn.textContent = 'Delete';
291 deleteBtn.addEventListener('click', async () => {
292 const confirmed = await showConfirmModal({
293 title: 'Delete Section',
294 message: 'Are you sure you want to delete this section?',
295 confirmText: 'Delete',
296 cancelText: 'Cancel',
297 confirmDanger: true,
298 });
299
300 if (confirmed) {
301 // If this is a content/block with a PDS record, delete it
302 if (this.section.type === 'content' || this.section.type === 'block') {
303 const parsed = this.section.ref ? parseAtUri(this.section.ref) : null;
304 const collection = parsed?.collection || this.section.collection;
305 const rkey = parsed?.rkey || this.section.rkey;
306
307 if (isContentTextCollection(collection) && rkey) {
308 try {
309 await deleteRecord(collection, rkey);
310 } catch (error) {
311 console.error('Failed to delete content record from PDS:', error);
312 // Continue with section removal even if PDS delete fails
313 }
314 }
315 }
316
317 // If this is an image record section, delete the PDS record too
318 if (this.section.type === 'records') {
319 const parsed = this.section.ref ? parseAtUri(this.section.ref) : null;
320 let collection = parsed?.collection || this.section.collection as string | undefined;
321 let rkey = parsed?.rkey || this.section.rkey as string | undefined;
322
323 if ((!collection || !rkey) && this.section.records?.[0]) {
324 const uri = this.section.records[0];
325 const parts = uri.split('/');
326 if (parts.length >= 5) {
327 collection = parts[3];
328 rkey = parts[4];
329 }
330 }
331
332 if (isContentImageCollection(collection) && rkey) {
333 try {
334 await deleteRecord(collection, rkey);
335 } catch (error) {
336 console.error('Failed to delete image record from PDS:', error);
337 // Continue with section removal even if PDS delete fails
338 }
339 }
340 }
341 await removeSection(this.section.id);
342
343 // Save the updated sections to PDS
344 try {
345 await saveConfig();
346 } catch (error) {
347 console.error('Failed to save sections after deletion:', error);
348 // Section is already removed from UI, but may need to be re-added on reload
349 }
350
351 this.remove();
352 }
353 });
354 actionButtons.appendChild(deleteBtn);
355 controls.appendChild(actionButtons);
356
357 return controls;
358 }
359
360 async updateInfoBox(infoBox: HTMLElement) {
361 let typeInfo = '';
362
363 if (this.section.type === 'content' || this.section.type === 'block') {
364 typeInfo = 'Content';
365 } else if (this.section.type === 'records') {
366 typeInfo = 'Loading...';
367 } else if (this.section.type === 'share-to-bluesky') {
368 typeInfo = 'Share on Bluesky';
369 } else {
370 typeInfo = this.section.type.charAt(0).toUpperCase() + this.section.type.slice(1);
371 }
372
373 infoBox.textContent = typeInfo;
374
375 // For records, fetch the actual $type asynchronously
376 if (this.section.type === 'records' && this.section.records && this.section.records.length > 0) {
377 try {
378 const record = await getRecordByUri(this.section.records[0]);
379 if (record && record.value && record.value.$type) {
380 infoBox.textContent = record.value.$type;
381 } else {
382 infoBox.textContent = 'Record';
383 }
384 } catch (error) {
385 console.error('Failed to load record type:', error);
386 infoBox.textContent = 'Record';
387 }
388 }
389 }
390
391 async editBlock() {
392 // Get or create the create-content modal
393 let modal = document.querySelector('create-content') as any;
394 if (!modal) {
395 modal = document.createElement('create-content');
396 document.body.appendChild(modal);
397 }
398
399 // Load existing block data
400 const ownerDid = getSiteOwnerDid();
401 const parsed = this.section.ref ? parseAtUri(this.section.ref) : null;
402 const collection = parsed?.collection || this.section.collection;
403 const rkey = parsed?.rkey || this.section.rkey;
404
405 if (isContentTextCollection(collection) && rkey && ownerDid) {
406 try {
407 const record = await getRecord(ownerDid, collection, rkey);
408 if (record && record.value) {
409 modal.editContent({
410 rkey,
411 sectionId: this.section.id,
412 title: record.value.title || this.section.title || '',
413 content: record.value.content || '',
414 format: record.value.format || this.section.format || 'markdown'
415 });
416 }
417 } catch (error) {
418 console.error('Failed to load content for editing:', error);
419 alert('Failed to load content for editing');
420 return;
421 }
422 } else {
423 // Fallback for inline content
424 modal.editContent({
425 sectionId: this.section.id,
426 title: this.section.title || '',
427 content: this.section.content || '',
428 format: this.section.format || 'text'
429 });
430 }
431
432 modal.setOnClose(() => {
433 this.render();
434 window.dispatchEvent(new CustomEvent('config-updated'));
435 });
436
437 modal.show();
438 }
439
440 async editProfile() {
441 // Get or create the create-profile modal
442 let modal = document.querySelector('create-profile') as any;
443 if (!modal) {
444 modal = document.createElement('create-profile');
445 document.body.appendChild(modal);
446 }
447
448 // Load existing profile data
449 const ownerDid = getSiteOwnerDid();
450 const parsedRef = this.section.ref ? parseAtUri(this.section.ref) : null;
451 const profileCollection = parsedRef?.collection || this.section.collection;
452 const profileRkey = parsedRef?.rkey || this.section.rkey || (isProfileCollection(profileCollection) ? 'self' : undefined);
453 if (isProfileCollection(profileCollection) && profileRkey && ownerDid) {
454 try {
455 const record = await getRecord(ownerDid, profileCollection, profileRkey);
456 if (record && record.value) {
457 modal.editProfile({
458 rkey: profileRkey,
459 sectionId: this.section.id,
460 displayName: record.value.displayName || '',
461 pronouns: record.value.pronouns || '',
462 description: record.value.description || '',
463 avatar: record.value.avatar || '',
464 banner: record.value.banner || ''
465 });
466 } else {
467 // No record found, create new one
468 modal.editProfile({
469 sectionId: this.section.id,
470 displayName: '',
471 pronouns: '',
472 description: '',
473 avatar: '',
474 banner: ''
475 });
476 }
477 } catch (error) {
478 console.error('Failed to load profile for editing:', error);
479 // If record doesn't exist, allow creating new one
480 modal.editProfile({
481 sectionId: this.section.id,
482 displayName: '',
483 pronouns: '',
484 description: '',
485 avatar: '',
486 banner: ''
487 });
488 }
489 } else {
490 // No rkey - create new profile record
491 modal.editProfile({
492 sectionId: this.section.id,
493 displayName: '',
494 pronouns: '',
495 description: '',
496 avatar: '',
497 banner: ''
498 });
499 }
500
501 modal.setOnClose(() => {
502 this.render();
503 window.dispatchEvent(new CustomEvent('config-updated'));
504 });
505
506 modal.show();
507 }
508
509 async editRecords() {
510 if (!this.section.records || this.section.records.length === 0) {
511 return;
512 }
513
514 let collection = this.section.collection as string | undefined;
515 let rkey = this.section.rkey as string | undefined;
516
517 if ((!collection || !rkey) && this.section.records?.[0]) {
518 const uri = this.section.records[0];
519 const parts = uri.split('/');
520 if (parts.length >= 5) {
521 collection = parts[3];
522 rkey = parts[4];
523 }
524 }
525
526 if (!isContentImageCollection(collection) || !rkey) {
527 return;
528 }
529
530 const ownerDid = getSiteOwnerDid();
531 if (!ownerDid) {
532 return;
533 }
534
535 let modal = document.querySelector('create-image') as any;
536 if (!modal) {
537 modal = document.createElement('create-image');
538 document.body.appendChild(modal);
539 }
540
541 try {
542 const record = await getRecord(ownerDid, collection, rkey);
543 if (record && record.value) {
544 let imageUrl = null;
545 if (record.value.image && (record.value.image.ref || record.value.image.$link)) {
546 imageUrl = await getBlobUrl(ownerDid, record.value.image);
547 }
548
549 modal.editImage({
550 rkey,
551 sectionId: this.section.id,
552 title: record.value.title || this.section.title || '',
553 imageUrl,
554 imageBlob: record.value.image || null,
555 createdAt: record.value.createdAt || null
556 });
557 }
558 } catch (error) {
559 console.error('Failed to load image record for editing:', error);
560 alert('Failed to load image record for editing');
561 return;
562 }
563
564 modal.setOnClose(() => {
565 this.render();
566 window.dispatchEvent(new CustomEvent('config-updated'));
567 });
568
569 modal.show();
570 }
571
572 async renderProfile(container) {
573 const ownerDid = getSiteOwnerDid();
574 if (!ownerDid) {
575 container.innerHTML = '<p>Login to create your garden</p>';
576 return;
577 }
578
579 // Show loading state
580 const loadingEl = createLoadingSpinner('Loading profile...');
581 container.innerHTML = '';
582 container.appendChild(loadingEl);
583
584 try {
585 let profileData = null;
586 const parsedProfileRef = this.section.ref ? parseAtUri(this.section.ref) : null;
587 let collectionToFetch = parsedProfileRef?.collection || this.section.collection;
588 let rkeyToFetch = parsedProfileRef?.rkey || this.section.rkey;
589
590 // Profile records are singletons at rkey 'self'
591 if (isProfileCollection(collectionToFetch) && !rkeyToFetch) {
592 rkeyToFetch = 'self';
593 }
594
595 if (isProfileCollection(collectionToFetch) && rkeyToFetch) {
596 try {
597 const record = await getRecord(ownerDid, collectionToFetch, rkeyToFetch);
598 if (record && record.value) {
599 let avatarUrl = null;
600 let bannerUrl = null;
601
602 if (record.value.avatar && (record.value.avatar.ref || record.value.avatar.$link)) {
603 avatarUrl = await getBlobUrl(ownerDid, record.value.avatar);
604 }
605 if (record.value.banner && (record.value.banner.ref || record.value.banner.$link)) {
606 bannerUrl = await getBlobUrl(ownerDid, record.value.banner);
607 }
608
609 profileData = {
610 displayName: record.value.displayName,
611 pronouns: record.value.pronouns,
612 description: record.value.description,
613 avatar: avatarUrl,
614 banner: bannerUrl
615 };
616 }
617 } catch (error) {
618 console.warn(`Failed to load custom profile from ${collectionToFetch}/${rkeyToFetch}, falling back to Bluesky profile:`, error);
619 }
620 }
621
622 // If no profileData yet, or if explicitly configured to use Bluesky profile, fetch Bluesky profile
623 if (!profileData || collectionToFetch === 'app.bsky.actor.profile') {
624 const profile = await getProfile(ownerDid);
625 if (!profile) {
626 throw new Error('Could not load profile');
627 }
628 profileData = {
629 displayName: profile.displayName,
630 pronouns: (profile as any).pronouns,
631 description: profile.description,
632 avatar: profile.avatar,
633 banner: profile.banner
634 };
635 }
636
637 // Convert profile to record format for layout
638 const record = {
639 value: {
640 title: profileData.displayName,
641 pronouns: profileData.pronouns,
642 content: profileData.description,
643 image: profileData.avatar,
644 banner: profileData.banner
645 }
646 };
647
648 const rendered = await renderRecord(record, this.section.layout || 'profile');
649 rendered.classList.add('content-enter');
650 container.innerHTML = '';
651 container.appendChild(rendered);
652 } catch (error) {
653 console.error('Failed to render profile:', error);
654 const errorEl = createErrorMessage(
655 'Failed to load profile',
656 async () => {
657 await this.renderProfile(container);
658 },
659 error instanceof Error ? error.message : String(error)
660 );
661 container.innerHTML = '';
662 container.appendChild(errorEl);
663 }
664 }
665
666 async renderRecords(container) {
667 const uris = this.section.records || [];
668
669 if (uris.length === 0) {
670 container.innerHTML = '<p class="empty">No records selected</p>';
671 return;
672 }
673
674 // Show loading state
675 const loadingEl = createLoadingSpinner('Loading records...');
676 container.innerHTML = '';
677 container.appendChild(loadingEl);
678
679 try {
680 const records = await getRecordsByUris(uris);
681
682 if (records.length === 0) {
683 container.innerHTML = '<p class="empty">Could not load selected records</p>';
684 return;
685 }
686
687 const grid = document.createElement('div');
688 grid.className = 'record-grid content-enter';
689
690 for (const record of records) {
691 try {
692 const rendered = await renderRecord(record, this.section.layout || 'card');
693 grid.appendChild(rendered);
694 } catch (error) {
695 console.error('Failed to render record:', error);
696 // Create error placeholder for this specific record
697 const errorPlaceholder = document.createElement('div');
698 errorPlaceholder.className = 'record-error';
699 const errorMsg = createErrorMessage(
700 'Failed to render record',
701 async () => {
702 try {
703 const rendered = await renderRecord(record, this.section.layout || 'card');
704 errorPlaceholder.replaceWith(rendered);
705 } catch (retryError) {
706 console.error('Retry failed:', retryError);
707 }
708 },
709 error instanceof Error ? error.message : String(error)
710 );
711 errorPlaceholder.appendChild(errorMsg);
712 grid.appendChild(errorPlaceholder);
713 }
714 }
715
716 container.innerHTML = '';
717 container.appendChild(grid);
718 } catch (error) {
719 console.error('Failed to load records:', error);
720 const errorEl = createErrorMessage(
721 'Failed to load records',
722 async () => {
723 await this.renderRecords(container);
724 },
725 error instanceof Error ? error.message : String(error)
726 );
727 container.innerHTML = '';
728 container.appendChild(errorEl);
729 }
730 }
731
732 async renderBlock(container) {
733 const ownerDid = getSiteOwnerDid();
734 let content = '';
735 let format = 'text';
736
737 // If section references a content record, load it from PDS
738 const parsedBlockRef = this.section.ref ? parseAtUri(this.section.ref) : null;
739 const blockCollection = parsedBlockRef?.collection || this.section.collection;
740 const blockRkey = parsedBlockRef?.rkey || this.section.rkey;
741
742 if (isContentTextCollection(blockCollection) && blockRkey && ownerDid) {
743 // Show loading state
744 const loadingEl = createLoadingSpinner('Loading content...');
745 container.innerHTML = '';
746 container.appendChild(loadingEl);
747
748 try {
749 const record = await getRecord(ownerDid, blockCollection, blockRkey);
750 if (record && record.value) {
751 content = record.value.content || '';
752 format = record.value.format || this.section.format || 'markdown';
753 } else {
754 throw new Error('Content record not found');
755 }
756 } catch (error) {
757 console.error('Failed to load content record:', error);
758 const errorEl = createErrorMessage(
759 'Failed to load content',
760 async () => {
761 await this.renderBlock(container);
762 },
763 error instanceof Error ? error.message : String(error)
764 );
765 container.innerHTML = '';
766 container.appendChild(errorEl);
767 return;
768 }
769 } else {
770 // Fall back to inline content (for backwards compatibility)
771 content = this.section.content || '';
772 format = this.section.format || 'text';
773 }
774
775 const contentDiv = document.createElement('div');
776 contentDiv.className = 'content content-enter';
777
778 try {
779 if (format === 'html') {
780 // Legacy support: existing HTML blocks still render, but sanitized.
781 contentDiv.innerHTML = sanitizeHtml(content);
782 } else if (format === 'markdown') {
783 contentDiv.innerHTML = renderMarkdown(content);
784 } else {
785 contentDiv.textContent = content;
786 }
787
788 container.innerHTML = '';
789 container.appendChild(contentDiv);
790
791 // In edit mode, make it editable (only for inline content, not records)
792 if (this.editMode && !this.section.ref && !this.section.rkey) {
793 contentDiv.contentEditable = 'true';
794 contentDiv.addEventListener('blur', () => {
795 updateSection(this.section.id, { content: contentDiv.innerText });
796 });
797 }
798 } catch (error) {
799 console.error('Failed to render content:', error);
800 const errorEl = createErrorMessage(
801 'Failed to render content',
802 async () => {
803 await this.renderBlock(container);
804 },
805 error instanceof Error ? error.message : String(error)
806 );
807 container.innerHTML = '';
808 container.appendChild(errorEl);
809 }
810 }
811
812 async renderShareToBluesky(container) {
813 const buttonContainer = document.createElement('div');
814 buttonContainer.className = 'share-to-bluesky-section';
815 buttonContainer.style.display = 'flex';
816 buttonContainer.style.justifyContent = 'center';
817 buttonContainer.style.padding = '1rem';
818
819 const button = document.createElement('button');
820 button.className = 'button button-primary';
821 button.textContent = 'Share on Bluesky';
822 button.setAttribute('aria-label', 'Share your garden to Bluesky');
823
824 // Dispatch custom event that site-app can listen for
825 button.addEventListener('click', () => {
826 const event = new CustomEvent('share-to-bluesky', {
827 bubbles: true,
828 cancelable: true
829 });
830 this.dispatchEvent(event);
831 });
832
833 buttonContainer.appendChild(button);
834 container.appendChild(buttonContainer);
835 }
836
837}
838
839customElements.define('section-block', SectionBlock);