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
at main 839 lines 29 kB view raw
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);