A frontend for your PDS
at main 746 lines 20 kB view raw
1<script lang="ts"> 2 import { Post } from "./pdsfetch"; 3 import { Config } from "../../config"; 4 import { onMount } from "svelte"; 5 import moment from "moment"; 6 import type { AppBskyFeedPost, AppBskyRichtextFacet } from "@atcute/client/lexicons"; 7 8 let { post }: { post: Post } = $props(); 9 10 // State for image carousel 11 let currentImageIndex = $state(0); 12 13 // State for lightbox 14 let lightboxImage = $state<{ url: string; index: number } | null>(null); 15 16 // Functions to navigate carousel 17 function nextImage() { 18 if (post.imagesCid && currentImageIndex < post.imagesCid.length - 1) { 19 currentImageIndex++; 20 } 21 } 22 23 function prevImage() { 24 if (currentImageIndex > 0) { 25 currentImageIndex--; 26 } 27 } 28 29 // Function to preload an image 30 function preloadImage(index: number): void { 31 if (!post.imagesCid || index < 0 || index >= post.imagesCid.length) return; 32 33 const img = new Image(); 34 img.src = `${Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${post.authorDid}&cid=${post.imagesCid[index]}`; 35 } 36 37 // Lightbox functions 38 function openLightbox(index: number) { 39 if (!post.imagesCid) return; 40 const url = `${Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${post.authorDid}&cid=${post.imagesCid[index]}`; 41 lightboxImage = { url, index }; 42 document.body.style.overflow = 'hidden'; 43 } 44 45 function closeLightbox() { 46 lightboxImage = null; 47 document.body.style.overflow = ''; 48 } 49 50 // Keyboard navigation for lightbox 51 function handleLightboxKeydown(e: KeyboardEvent) { 52 if (!lightboxImage || !post.imagesCid) return; 53 54 if (e.key === 'Escape') { 55 closeLightbox(); 56 } else if (e.key === 'ArrowLeft' && lightboxImage.index > 0) { 57 lightboxImage = { 58 url: `${Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${post.authorDid}&cid=${post.imagesCid[lightboxImage.index - 1]}`, 59 index: lightboxImage.index - 1 60 }; 61 } else if (e.key === 'ArrowRight' && lightboxImage.index < post.imagesCid.length - 1) { 62 lightboxImage = { 63 url: `${Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${post.authorDid}&cid=${post.imagesCid[lightboxImage.index + 1]}`, 64 index: lightboxImage.index + 1 65 }; 66 } 67 } 68 69 // Rich text rendering function 70 function renderRichText(text: string, facets: any[] | null): string { 71 if (!facets || facets.length === 0) return escapeHtml(text); 72 73 const sortedFacets = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart); 74 75 // Convert text to UTF-8 bytes for proper facet indexing 76 const encoder = new TextEncoder(); 77 const decoder = new TextDecoder(); 78 const bytes = encoder.encode(text); 79 80 let result = ''; 81 let lastByteIndex = 0; 82 83 for (const facet of sortedFacets) { 84 const { byteStart, byteEnd } = facet.index; 85 86 // Extract text before facet 87 if (lastByteIndex < byteStart) { 88 const beforeBytes = bytes.slice(lastByteIndex, byteStart); 89 result += escapeHtml(decoder.decode(beforeBytes)); 90 } 91 92 // Extract facet text 93 const facetBytes = bytes.slice(byteStart, byteEnd); 94 const facetText = decoder.decode(facetBytes); 95 const feature = facet.features?.[0]; 96 97 if (feature) { 98 if (feature.$type === 'app.bsky.richtext.facet#link') { 99 result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer" class="post-link">${escapeHtml(facetText)}</a>`; 100 } else if (feature.$type === 'app.bsky.richtext.facet#mention') { 101 result += `<a href="${Config.FRONTEND_URL}/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer" class="post-mention">${escapeHtml(facetText)}</a>`; 102 } else if (feature.$type === 'app.bsky.richtext.facet#tag') { 103 result += `<a href="${Config.FRONTEND_URL}/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer" class="post-hashtag">${escapeHtml(facetText)}</a>`; 104 } else { 105 result += escapeHtml(facetText); 106 } 107 } else { 108 result += escapeHtml(facetText); 109 } 110 111 lastByteIndex = byteEnd; 112 } 113 114 // Add remaining text after last facet 115 if (lastByteIndex < bytes.length) { 116 const remainingBytes = bytes.slice(lastByteIndex); 117 result += escapeHtml(decoder.decode(remainingBytes)); 118 } 119 120 return result; 121 } 122 123 function escapeHtml(text: string): string { 124 const div = document.createElement('div'); 125 div.textContent = text; 126 return div.innerHTML; 127 } 128 129 130 131 // Preload adjacent images when current index changes 132 $effect(() => { 133 if (post.imagesCid && post.imagesCid.length > 1) { 134 // Preload next image if available 135 if (currentImageIndex < post.imagesCid.length - 1) { 136 preloadImage(currentImageIndex + 1); 137 } 138 139 // Preload previous image if available 140 if (currentImageIndex > 0) { 141 preloadImage(currentImageIndex - 1); 142 } 143 } 144 }); 145 146 // Initial preload of images 147 onMount(() => { 148 if (post.imagesCid && post.imagesCid.length > 1) { 149 // Preload the next image if it exists 150 if (post.imagesCid.length > 1) { 151 preloadImage(1); 152 } 153 } 154 155 // Add keyboard listener for lightbox 156 window.addEventListener('keydown', handleLightboxKeydown); 157 return () => { 158 window.removeEventListener('keydown', handleLightboxKeydown); 159 }; 160 }); 161</script> 162 163<div id="postContainer"> 164 <div id="postHeader"> 165 {#if post.authorAvatarCid} 166 <img 167 id="avatar" 168 src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={post.authorDid}&cid={post.authorAvatarCid}" 169 alt="avatar of {post.displayName}" 170 /> 171 {:else} 172 <div class="avatar-placeholder"> 173 <span>{post.displayName.charAt(0).toUpperCase()}</span> 174 </div> 175 {/if} 176 <div id="headerText"> 177 <a id="displayName" href="{Config.FRONTEND_URL}/profile/{post.authorHandle}" 178 >{post.displayName}</a 179 > 180 <p id="handle"> 181 <a href="{Config.FRONTEND_URL}/profile/{post.authorHandle}" 182 >@{post.authorHandle}</a 183 > 184 <span class="separator">·</span> 185 <a 186 id="postLink" 187 href="{Config.FRONTEND_URL}/profile/{post.authorHandle}/post/{post.recordName}" 188 >{moment(post.timenotstamp).isBefore(moment().subtract(1, "month")) 189 ? moment(post.timenotstamp).format("MMM D, YYYY") 190 : moment(post.timenotstamp).fromNow()}</a 191 > 192 </p> 193 </div> 194 </div> 195 <div id="postContent"> 196 {#if post.replyingUri} 197 <div class="context-badge reply-badge"> 198 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 199 <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/> 200 </svg> 201 <span>Replying to 202 <a 203 href="{Config.FRONTEND_URL}/profile/{post.replyingHandle || post.replyingUri.repo}/post/{post.replyingUri.rkey}" 204 class="context-link" 205 >@{post.replyingHandle || post.replyingUri.repo}</a 206 > 207 </span> 208 </div> 209 {/if} 210 {#if post.quotingUri} 211 <div class="context-badge quote-badge"> 212 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 213 <polyline points="17 1 21 5 17 9"></polyline> 214 <path d="M3 11V9a4 4 0 0 1 4-4h14"></path> 215 <polyline points="7 23 3 19 7 15"></polyline> 216 <path d="M21 13v2a4 4 0 0 1-4 4H3"></path> 217 </svg> 218 <span>Quoting 219 <a 220 href="{Config.FRONTEND_URL}/profile/{post.quotingHandle || post.quotingUri.repo}/post/{post.quotingUri.rkey}" 221 class="context-link" 222 >@{post.quotingHandle || post.quotingUri.repo}</a 223 > 224 </span> 225 </div> 226 {/if} 227 228 <!-- Rich text with facets support --> 229 <div id="postText"> 230 {@html renderRichText(post.text, post.facets)} 231 </div> 232 233 <!-- External Link Card --> 234 {#if post.externalLink} 235 <a 236 href={post.externalLink.uri} 237 target="_blank" 238 rel="noopener noreferrer" 239 class="external-link-card" 240 > 241 {#if post.externalLink.thumb} 242 <img 243 src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={post.authorDid}&cid={post.externalLink.thumb}" 244 alt={post.externalLink.title} 245 class="external-link-thumb" 246 loading="lazy" 247 /> 248 {/if} 249 <div class="external-link-content"> 250 <h3 class="external-link-title"> 251 {post.externalLink.title} 252 </h3> 253 {#if post.externalLink.description} 254 <p class="external-link-description"> 255 {post.externalLink.description} 256 </p> 257 {/if} 258 <p class="external-link-domain"> 259 {new URL(post.externalLink.uri).hostname} 260 </p> 261 </div> 262 </a> 263 {/if} 264 265 <!-- Images with carousel and lightbox --> 266 {#if post.imagesCid && post.imagesCid.length > 0} 267 <div id="carouselContainer"> 268 <button 269 type="button" 270 class="image-button" 271 onclick={() => openLightbox(currentImageIndex)} 272 > 273 <img 274 id="embedImages" 275 alt="Post Image {currentImageIndex + 1} of {post.imagesCid.length}" 276 src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={post.authorDid}&cid={post.imagesCid[currentImageIndex]}" 277 /> 278 </button> 279 280 {#if post.imagesCid.length > 1} 281 <div id="carouselControls"> 282 <button 283 id="prevBtn" 284 onclick={prevImage} 285 disabled={currentImageIndex === 0} 286 aria-label="Previous image" 287 ></button 288 > 289 <div id="carouselIndicators"> 290 {#each post.imagesCid as _, i} 291 <button 292 type="button" 293 class="indicator {i === currentImageIndex ? 'active' : ''}" 294 onclick={() => { currentImageIndex = i; }} 295 aria-label="Go to image {i + 1}" 296 ></button> 297 {/each} 298 </div> 299 <button 300 id="nextBtn" 301 onclick={nextImage} 302 disabled={currentImageIndex === post.imagesCid.length - 1} 303 aria-label="Next image" 304 ></button 305 > 306 </div> 307 {/if} 308 </div> 309 {/if} 310 311 <!-- Video --> 312 {#if post.videosLinkCid} 313 <video 314 id="embedVideo" 315 src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={post.authorDid}&cid={post.videosLinkCid}" 316 controls 317 preload="metadata" 318 > 319 <track kind="captions" /> 320 Your browser does not support the video tag. 321 </video> 322 {/if} 323 324 <!-- GIF --> 325 {#if post.gifLink} 326 <img 327 id="embedVideo" 328 src="{post.gifLink}" 329 alt="Post GIF" 330 /> 331 {/if} 332 </div> 333</div> 334 335<!-- Lightbox Modal --> 336{#if lightboxImage && post.imagesCid} 337 <div 338 class="lightbox-overlay" 339 onclick={closeLightbox} 340 onkeydown={(e) => e.key === 'Escape' && closeLightbox()} 341 role="button" 342 tabindex="0" 343 aria-label="Close image lightbox" 344 > 345 <button 346 type="button" 347 class="lightbox-close" 348 onclick={closeLightbox} 349 aria-label="Close" 350 > 351 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 352 <line x1="18" y1="6" x2="6" y2="18"></line> 353 <line x1="6" y1="6" x2="18" y2="18"></line> 354 </svg> 355 </button> 356 357 {#if post.imagesCid.length > 1} 358 <button 359 type="button" 360 class="lightbox-nav lightbox-nav-prev" 361 onclick={(e) => { 362 e.stopPropagation(); 363 if (lightboxImage && lightboxImage.index > 0) { 364 lightboxImage = { 365 url: `${Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${post.authorDid}&cid=${post.imagesCid![lightboxImage.index - 1]}`, 366 index: lightboxImage.index - 1 367 }; 368 } 369 }} 370 disabled={lightboxImage.index === 0} 371 aria-label="Previous image" 372 > 373 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 374 <polyline points="15 18 9 12 15 6"></polyline> 375 </svg> 376 </button> 377 378 <button 379 type="button" 380 class="lightbox-nav lightbox-nav-next" 381 onclick={(e) => { 382 e.stopPropagation(); 383 if (lightboxImage && post.imagesCid && lightboxImage.index < post.imagesCid.length - 1) { 384 lightboxImage = { 385 url: `${Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${post.authorDid}&cid=${post.imagesCid[lightboxImage.index + 1]}`, 386 index: lightboxImage.index + 1 387 }; 388 } 389 }} 390 disabled={lightboxImage.index === post.imagesCid.length - 1} 391 aria-label="Next image" 392 > 393 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 394 <polyline points="9 18 15 12 9 6"></polyline> 395 </svg> 396 </button> 397 {/if} 398 399 <div class="lightbox-content" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1"> 400 <img 401 src={lightboxImage.url} 402 alt="Full size post image {lightboxImage.index + 1}" 403 class="lightbox-image" 404 /> 405 {#if post.imagesCid.length > 1} 406 <div class="lightbox-counter"> 407 {lightboxImage.index + 1} / {post.imagesCid.length} 408 </div> 409 {/if} 410 </div> 411 </div> 412{/if} 413 414<style> 415 /* Avatar placeholder */ 416 .avatar-placeholder { 417 width: 48px; 418 height: 48px; 419 border-radius: 50%; 420 background: linear-gradient(135deg, var(--link-color), var(--time-color)); 421 display: flex; 422 align-items: center; 423 justify-content: center; 424 color: white; 425 font-weight: 600; 426 font-size: 1.2em; 427 border: 2px solid white; 428 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 429 } 430 431 /* Context badges */ 432 .context-badge { 433 display: inline-flex; 434 align-items: center; 435 gap: 6px; 436 font-size: 0.85em; 437 padding: 6px 12px; 438 border-radius: 12px; 439 margin-bottom: 10px; 440 font-weight: 500; 441 } 442 443 .reply-badge { 444 background-color: rgba(99, 102, 241, 0.1); 445 color: #6366f1; 446 border: 1px solid rgba(99, 102, 241, 0.2); 447 } 448 449 .quote-badge { 450 background-color: rgba(139, 92, 246, 0.1); 451 color: #8b5cf6; 452 border: 1px solid rgba(139, 92, 246, 0.2); 453 } 454 455 .context-badge svg { 456 flex-shrink: 0; 457 } 458 459 .context-link { 460 color: inherit; 461 font-weight: 600; 462 text-decoration: none; 463 } 464 465 .context-link:hover { 466 text-decoration: underline; 467 } 468 469 .separator { 470 color: #9ca3af; 471 margin: 0 4px; 472 } 473 474 /* Rich text links */ 475 :global(.post-link) { 476 color: var(--link-color); 477 text-decoration: none; 478 transition: color 0.15s ease; 479 } 480 481 :global(.post-link:hover) { 482 color: var(--link-hover-color); 483 text-decoration: underline; 484 } 485 486 :global(.post-mention) { 487 color: var(--link-color); 488 font-weight: 500; 489 text-decoration: none; 490 transition: color 0.15s ease; 491 } 492 493 :global(.post-mention:hover) { 494 color: var(--link-hover-color); 495 text-decoration: underline; 496 } 497 498 :global(.post-hashtag) { 499 color: var(--time-color); 500 font-weight: 500; 501 text-decoration: none; 502 transition: color 0.15s ease; 503 } 504 505 :global(.post-hashtag:hover) { 506 opacity: 0.8; 507 text-decoration: underline; 508 } 509 510 /* Image button for lightbox */ 511 .image-button { 512 all: unset; 513 cursor: pointer; 514 display: block; 515 width: 100%; 516 } 517 518 .image-button:focus-visible { 519 outline: 2px solid var(--link-color); 520 outline-offset: 2px; 521 border-radius: 8px; 522 } 523 524 /* Make indicators clickable */ 525 .indicator { 526 cursor: pointer; 527 border: none; 528 padding: 0; 529 } 530 531 .indicator:focus-visible { 532 outline: 2px solid var(--link-color); 533 outline-offset: 2px; 534 } 535 536 /* Lightbox styles */ 537 .lightbox-overlay { 538 position: fixed; 539 inset: 0; 540 z-index: 1000; 541 display: flex; 542 align-items: center; 543 justify-content: center; 544 background-color: rgba(0, 0, 0, 0.95); 545 padding: 20px; 546 cursor: zoom-out; 547 } 548 549 .lightbox-content { 550 position: relative; 551 display: flex; 552 flex-direction: column; 553 align-items: center; 554 max-width: 90vw; 555 max-height: 90vh; 556 cursor: default; 557 } 558 559 .lightbox-image { 560 max-width: 100%; 561 max-height: 85vh; 562 object-fit: contain; 563 border-radius: 8px; 564 box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); 565 } 566 567 .lightbox-close { 568 position: absolute; 569 top: 20px; 570 right: 20px; 571 z-index: 1001; 572 background-color: rgba(0, 0, 0, 0.7); 573 border: none; 574 border-radius: 50%; 575 width: 44px; 576 height: 44px; 577 display: flex; 578 align-items: center; 579 justify-content: center; 580 cursor: pointer; 581 color: white; 582 transition: background-color 0.2s ease, transform 0.2s ease; 583 } 584 585 .lightbox-close:hover { 586 background-color: rgba(0, 0, 0, 0.9); 587 transform: scale(1.1); 588 } 589 590 .lightbox-close:focus-visible { 591 outline: 2px solid white; 592 outline-offset: 2px; 593 } 594 595 .lightbox-nav { 596 position: absolute; 597 top: 50%; 598 transform: translateY(-50%); 599 z-index: 1001; 600 background-color: rgba(0, 0, 0, 0.7); 601 border: none; 602 border-radius: 50%; 603 width: 44px; 604 height: 44px; 605 display: flex; 606 align-items: center; 607 justify-content: center; 608 cursor: pointer; 609 color: white; 610 transition: background-color 0.2s ease, transform 0.2s ease, opacity 0.2s ease; 611 } 612 613 .lightbox-nav:hover:not(:disabled) { 614 background-color: rgba(0, 0, 0, 0.9); 615 transform: translateY(-50%) scale(1.1); 616 } 617 618 .lightbox-nav:disabled { 619 opacity: 0.3; 620 cursor: not-allowed; 621 } 622 623 .lightbox-nav:focus-visible { 624 outline: 2px solid white; 625 outline-offset: 2px; 626 } 627 628 .lightbox-nav-prev { 629 left: 20px; 630 } 631 632 .lightbox-nav-next { 633 right: 20px; 634 } 635 636 .lightbox-counter { 637 margin-top: 16px; 638 padding: 8px 16px; 639 background-color: rgba(0, 0, 0, 0.7); 640 border-radius: 20px; 641 color: white; 642 font-size: 0.9em; 643 font-weight: 500; 644 } 645 646 /* External link card */ 647 .external-link-card { 648 display: flex; 649 flex-direction: column; 650 overflow: hidden; 651 border-radius: 12px; 652 border: 1px solid var(--border-color); 653 background-color: var(--header-background-color); 654 margin-top: 12px; 655 transition: background-color 0.15s ease, transform 0.15s ease; 656 text-decoration: none; 657 color: inherit; 658 } 659 660 .external-link-card:hover { 661 background-color: var(--button-hover); 662 transform: translateY(-2px); 663 } 664 665 .external-link-thumb { 666 width: 100%; 667 height: 200px; 668 object-fit: cover; 669 background-color: var(--border-color); 670 } 671 672 .external-link-content { 673 padding: 12px; 674 } 675 676 .external-link-title { 677 font-size: 0.95em; 678 font-weight: 600; 679 color: var(--text-color); 680 margin: 0 0 6px 0; 681 line-height: 1.4; 682 display: -webkit-box; 683 -webkit-line-clamp: 2; 684 line-clamp: 2; 685 -webkit-box-orient: vertical; 686 overflow: hidden; 687 } 688 689 .external-link-description { 690 font-size: 0.85em; 691 color: var(--text-secondary-color); 692 margin: 0 0 8px 0; 693 line-height: 1.4; 694 display: -webkit-box; 695 -webkit-line-clamp: 2; 696 line-clamp: 2; 697 -webkit-box-orient: vertical; 698 overflow: hidden; 699 } 700 701 .external-link-domain { 702 font-size: 0.8em; 703 color: #9ca3af; 704 margin: 0; 705 } 706 707 /* Mobile responsiveness for lightbox */ 708 @media screen and (max-width: 768px) { 709 .lightbox-overlay { 710 padding: 10px; 711 } 712 713 .lightbox-image { 714 max-height: 80vh; 715 } 716 717 .lightbox-close { 718 top: 10px; 719 right: 10px; 720 width: 36px; 721 height: 36px; 722 } 723 724 .lightbox-nav { 725 width: 36px; 726 height: 36px; 727 } 728 729 .lightbox-nav-prev { 730 left: 10px; 731 } 732 733 .lightbox-nav-next { 734 right: 10px; 735 } 736 737 .lightbox-counter { 738 font-size: 0.8em; 739 padding: 6px 12px; 740 } 741 742 .external-link-thumb { 743 height: 160px; 744 } 745 } 746</style>