replies timeline only, appview-less bluesky client

implement photo gallery and lightbox

ptr.pet c0ff5074 d5bd716b

verified
+5
deno.lock
··· 32 32 "npm:globals@17": "17.0.0", 33 33 "npm:hash-wasm@^4.12.0": "4.12.0", 34 34 "npm:lru-cache@^11.2.4": "11.2.4", 35 + "npm:photoswipe@^5.4.4": "5.4.4", 35 36 "npm:prettier-plugin-svelte@^3.4.1": "3.4.1_prettier@3.7.4_svelte@5.46.1__acorn@8.15.0", 36 37 "npm:prettier-plugin-tailwindcss@~0.7.2": "0.7.2_prettier@3.7.4_prettier-plugin-svelte@3.4.1__prettier@3.7.4__svelte@5.46.1___acorn@8.15.0_svelte@5.46.1__acorn@8.15.0", 37 38 "npm:prettier@^3.7.4": "3.7.4", ··· 1486 1487 "path-key@3.1.1": { 1487 1488 "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" 1488 1489 }, 1490 + "photoswipe@5.4.4": { 1491 + "integrity": "sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==" 1492 + }, 1489 1493 "picocolors@1.1.1": { 1490 1494 "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" 1491 1495 }, ··· 1869 1873 "npm:globals@17", 1870 1874 "npm:hash-wasm@^4.12.0", 1871 1875 "npm:lru-cache@^11.2.4", 1876 + "npm:photoswipe@^5.4.4", 1872 1877 "npm:prettier-plugin-svelte@^3.4.1", 1873 1878 "npm:prettier-plugin-tailwindcss@~0.7.2", 1874 1879 "npm:prettier@^3.7.4",
+1
package.json
··· 32 32 "async-cache-dedupe": "^3.4.0", 33 33 "hash-wasm": "^4.12.0", 34 34 "lru-cache": "^11.2.4", 35 + "photoswipe": "^5.4.4", 35 36 "svelte-device-info": "^1.0.6", 36 37 "svelte-infinite": "^0.5.1", 37 38 "svelte-portal": "^2.2.1"
+70 -17
src/components/BskyPost.svelte
··· 43 43 import { getRelativeTime } from '$lib/date'; 44 44 import { likeSource, repostSource, toCanonicalUri } from '$lib'; 45 45 import ProfileInfo from './ProfileInfo.svelte'; 46 + import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte'; 46 47 47 48 interface Props { 48 49 client: AtpClient; ··· 95 96 if (!p.ok) return; 96 97 profile = p.value; 97 98 profiles.set(did, profile); 98 - // console.log(profile.description); 99 99 }); 100 100 101 - const postId = $derived(`timeline-post-${aturi}-${quoteDepth}`); 101 + const postId = $derived( 102 + `timeline-post-${did.replace(/[^a-zA-Z0-9]/g, '_')}-${rkey}-${quoteDepth}` 103 + ); 102 104 const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); 103 105 104 - // todo: this fucking sucks 105 106 const scrollToAndPulse = (targetUri: ResourceUri) => { 106 107 const targetId = `timeline-post-${targetUri}-0`; 107 - // console.log(`Scrolling to ${targetId}`); 108 108 const element = document.getElementById(targetId); 109 109 if (!element) return; 110 110 ··· 116 116 generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo) 117 117 ); 118 118 pulsingPostId.set(targetId); 119 - // Clear pulse after animation 120 119 setTimeout(() => pulsingPostId.set(null), 1200); 121 120 }, 400); 122 121 }; ··· 212 211 </button> 213 212 {/snippet} 214 213 215 - <!-- eslint-disable svelte/no-navigation-without-resolve --> 216 214 {#snippet profilePopout()} 217 215 <Dropdown 218 216 class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!" ··· 328 326 <!-- svelte-ignore a11y_no_static_element_interactions --> 329 327 <div oncontextmenu={(e) => e.stopPropagation()}> 330 328 {#if embed.$type === 'app.bsky.embed.images'} 331 - <!-- todo: improve how images are displayed, and pop out on click --> 332 - {#each embed.images as image (image.image)} 333 - {#if isBlob(image.image)} 334 - <img 335 - class="w-full rounded-sm" 336 - src={img('feed_thumbnail', did, image.image.ref.$link)} 337 - alt={image.alt} 338 - /> 339 - {/if} 340 - {/each} 329 + {@const _images = embed.images.flatMap((img) => 330 + isBlob(img.image) ? [{ ...img, image: img.image }] : [] 331 + )} 332 + {@const images = _images.map((i): GalleryItem => { 333 + const sizeFactor = 200; 334 + const size = { 335 + width: (i.aspectRatio?.width ?? 4) * sizeFactor, 336 + height: (i.aspectRatio?.height ?? 3) * sizeFactor 337 + }; 338 + return { 339 + ...size, 340 + src: img('feed_fullsize', did, i.image.ref.$link), 341 + thumbnail: { 342 + src: img('feed_thumbnail', did, i.image.ref.$link), 343 + ...size 344 + } 345 + }; 346 + })} 347 + <PhotoSwipeGallery {images} /> 341 348 {:else if embed.$type === 'app.bsky.embed.video'} 342 349 {#if isBlob(embed.video)} 343 350 {#await didDoc then didDoc} ··· 385 392 {@render embedMedia(embed.media)} 386 393 </div> 387 394 {/if} 388 - <!-- todo: implement external link embeds --> 389 395 {/snippet} 390 396 391 397 {#snippet postControls(post: PostWithUri)} ··· 535 541 536 542 :global(.post-dropdown) { 537 543 @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; 544 + } 545 + 546 + .image-grid { 547 + display: grid; 548 + gap: 2px; 549 + border-radius: 0.375rem; 550 + overflow: hidden; 551 + max-height: 500px; 552 + } 553 + 554 + /* 1 image: full width */ 555 + .image-grid.count-1 { 556 + grid-template-columns: 1fr; 557 + } 558 + 559 + /* 2 images: side by side */ 560 + .image-grid.count-2 { 561 + grid-template-columns: repeat(2, 1fr); 562 + } 563 + 564 + /* 3 images: first spans left, two stack on right */ 565 + .image-grid.count-3 { 566 + grid-template-columns: repeat(2, 1fr); 567 + grid-template-rows: repeat(2, 1fr); 568 + } 569 + .image-grid.count-3 a:first-child { 570 + grid-row: 1 / 3; 571 + } 572 + 573 + /* 4+ images: 2x2 grid */ 574 + .image-grid.count-4, 575 + .image-grid.count-5 { 576 + grid-template-columns: repeat(2, 1fr); 577 + grid-template-rows: repeat(2, 1fr); 578 + } 579 + 580 + .image-item { 581 + width: 100%; 582 + height: 100%; 583 + object-fit: cover; 584 + display: block; 585 + cursor: pointer; 586 + transition: opacity 0.2s; 587 + } 588 + 589 + .image-item:hover { 590 + opacity: 0.9; 538 591 } 539 592 </style>
+199
src/components/PhotoSwipeGallery.svelte
··· 1 + <script context="module" lang="ts"> 2 + export interface GalleryItem { 3 + src: string; 4 + thumbnail?: { 5 + src: string; 6 + width: number; 7 + height: number; 8 + }; 9 + width: number; 10 + height: number; 11 + cropped?: boolean; 12 + alt?: string; 13 + } 14 + export type GalleryData = Array<GalleryItem>; 15 + </script> 16 + 17 + <script lang="ts"> 18 + import 'photoswipe/photoswipe.css'; 19 + import PhotoSwipeLightbox from 'photoswipe/lightbox'; 20 + import PhotoSwipe, { type ElementProvider, type PreparedPhotoSwipeOptions } from 'photoswipe'; 21 + import { onMount } from 'svelte'; 22 + import { writable } from 'svelte/store'; 23 + 24 + export let images: GalleryData; 25 + let element: HTMLDivElement; 26 + 27 + const options = writable<Partial<PreparedPhotoSwipeOptions> | undefined>(undefined); 28 + $: { 29 + if (!element) break $; 30 + const opts: Partial<PreparedPhotoSwipeOptions> = { 31 + pswpModule: PhotoSwipe, 32 + children: element.childNodes as ElementProvider, 33 + gallery: element, 34 + hideAnimationDuration: 0, 35 + showAnimationDuration: 0, 36 + zoomAnimationDuration: 200, 37 + zoomSVG: 38 + '<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" d="M6.25 8.75v-1h-1a.75.75 0 0 1 0-1.5h1v-1a.75.75 0 0 1 1.5 0v1h1a.75.75 0 0 1 0 1.5h-1v1a.75.75 0 0 1-1.5 0"/><path fill="currentColor" fill-rule="evenodd" d="M7 12c1.11 0 2.136-.362 2.965-.974l2.755 2.754a.75.75 0 1 0 1.06-1.06l-2.754-2.755A5 5 0 1 0 7 12m0-1.5a3.5 3.5 0 1 0 0-7a3.5 3.5 0 0 0 0 7" clip-rule="evenodd"/></svg>', 39 + closeSVG: 40 + '<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94z"/></svg>', 41 + arrowPrevSVG: 42 + '<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M9.78 4.22a.75.75 0 0 1 0 1.06L7.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L5.47 8.53a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0" clip-rule="evenodd"/></svg>', 43 + arrowNextSVG: 44 + '<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8L6.22 5.28a.75.75 0 0 1 0-1.06" clip-rule="evenodd"/></svg>' 45 + }; 46 + $options = opts; 47 + } 48 + 49 + onMount(() => { 50 + let lightbox: PhotoSwipeLightbox | undefined; 51 + const unsub = options.subscribe((opts) => { 52 + lightbox?.destroy?.(); 53 + if (opts === undefined) return; 54 + lightbox = new PhotoSwipeLightbox(opts); 55 + lightbox.init(); 56 + }); 57 + return () => { 58 + unsub(); 59 + lightbox?.destroy?.(); 60 + }; 61 + }); 62 + </script> 63 + 64 + <div class="gallery styling-twitter" data-total={images.length} bind:this={element}> 65 + {#each images as img, i (img.src)} 66 + {@const thumb = img.thumbnail ?? img} 67 + {@const isHidden = i > 3} 68 + {@const isOverlay = i === 3 && images.length > 4} 69 + 70 + <a 71 + href={img.src} 72 + data-pswp-width={img.width} 73 + data-pswp-height={img.height} 74 + target="_blank" 75 + class:hidden-in-grid={isHidden} 76 + class:overlay-container={isOverlay} 77 + > 78 + <img src={thumb.src} alt={img.alt ?? ''} width={thumb.width} height={thumb.height} /> 79 + 80 + {#if isOverlay} 81 + <div class="more-overlay"> 82 + +{images.length - 4} 83 + </div> 84 + {/if} 85 + </a> 86 + {/each} 87 + </div> 88 + 89 + <style> 90 + :global(.gallery--icon) { 91 + --drop-color: color-mix(in srgb, var(--color-gray-900) 70%, transparent); 92 + color: var(--nucleus-fg); 93 + filter: drop-shadow(2px 2px 1px var(--drop-color)) drop-shadow(-2px -2px 1px var(--drop-color)) 94 + drop-shadow(-2px 2px 1px var(--drop-color)) drop-shadow(2px -2px 1px var(--drop-color)); 95 + } 96 + 97 + /* --- Default Grid (for 2+ images) --- */ 98 + .gallery.styling-twitter { 99 + display: grid; 100 + gap: 2px; 101 + border-radius: 4px; 102 + overflow: hidden; 103 + width: 100%; 104 + } 105 + 106 + .gallery.styling-twitter > a { 107 + width: 100%; 108 + height: 100%; 109 + display: block; 110 + position: relative; 111 + overflow: hidden; 112 + } 113 + 114 + .gallery.styling-twitter > a > img { 115 + @apply transition-opacity duration-200 hover:opacity-80; 116 + width: 100%; 117 + height: 100%; 118 + object-fit: cover; /* Standard tile crop */ 119 + } 120 + 121 + /* --- SINGLE IMAGE OVERRIDES --- */ 122 + /* This configuration allows the image to determine the width/height 123 + naturally based on aspect ratio, up to a max-height limit. 124 + */ 125 + .gallery.styling-twitter[data-total='1'] { 126 + display: block; /* Remove grid constraints */ 127 + height: auto; 128 + aspect-ratio: auto; /* Remove 16:9 ratio */ 129 + border-radius: 0; 130 + } 131 + 132 + .gallery.styling-twitter[data-total='1'] > a { 133 + /* fit-content is key: the container shrinks to fit the image width */ 134 + width: fit-content; 135 + height: auto; 136 + display: block; 137 + border-radius: 4px; 138 + overflow: hidden; 139 + max-width: 100%; /* Prevent overflowing the parent */ 140 + } 141 + 142 + .gallery.styling-twitter[data-total='1'] > a > img { 143 + /* Let dimensions flow naturally */ 144 + width: auto; 145 + height: auto; 146 + 147 + /* Constraints: */ 148 + max-width: 100%; /* Never wider than container */ 149 + max-height: 60vh; /* Never taller than 60% of viewport (adjust if needed) */ 150 + 151 + object-fit: contain; /* Never crop the single image */ 152 + } 153 + 154 + /* --- Grid Layouts (2+ Images) --- */ 155 + /* These retain the standard grid look */ 156 + 157 + /* 2 Images: Split vertically */ 158 + .gallery.styling-twitter[data-total='2'] { 159 + grid-template-columns: 1fr 1fr; 160 + grid-template-rows: 1fr; 161 + aspect-ratio: 16/9; 162 + } 163 + 164 + /* 3 Images: 1 Big (left), 2 Small (stacked right) */ 165 + .gallery.styling-twitter[data-total='3'] { 166 + grid-template-columns: 1fr 1fr; 167 + grid-template-rows: 1fr 1fr; 168 + aspect-ratio: 16/9; 169 + } 170 + .gallery.styling-twitter[data-total='3'] > a:first-child { 171 + grid-row: span 2; 172 + } 173 + 174 + /* 4+ Images: 2x2 Grid */ 175 + .gallery.styling-twitter[data-total='4'], 176 + .gallery.styling-twitter[data-total^='5'], 177 + .gallery.styling-twitter:not([data-total='1']):not([data-total='2']):not([data-total='3']) { 178 + grid-template-columns: 1fr 1fr; 179 + grid-template-rows: 1fr 1fr; 180 + aspect-ratio: 16/9; 181 + } 182 + 183 + .gallery.styling-twitter .hidden-in-grid { 184 + display: none; 185 + } 186 + 187 + .more-overlay { 188 + position: absolute; 189 + inset: 0; 190 + background-color: rgba(0, 0, 0, 0.5); 191 + color: white; 192 + display: flex; 193 + align-items: center; 194 + justify-content: center; 195 + font-size: 2rem; 196 + font-weight: bold; 197 + pointer-events: none; 198 + } 199 + </style>
+1 -4
src/components/PostComposer.svelte
··· 174 174 {_state.text.length} / 300 175 175 </span> 176 176 <button 177 - onmousedown={(e) => { 178 - e.preventDefault(); 179 - doPost(); 180 - }} 177 + onclick={doPost} 181 178 disabled={_state.text.length === 0 || _state.text.length > 300} 182 179 class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed! disabled:opacity-50 disabled:hover:scale-100" 183 180 style="background: color-mix(in srgb, {color} 87%, transparent);"