A frontend for your PDS
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>