feat(media): swipeable peeking-tile gallery for multi-image posts#425
feat(media): swipeable peeking-tile gallery for multi-image posts#425dmnyc wants to merge 4 commits into
Conversation
656fcdc to
bef50b7
Compare
There was a problem hiding this comment.
Pull request overview
Adds a shared swipeable media gallery/lightbox experience for multi-image posts across the feed and threads, plus improved single-image sizing and mouse drag-to-swipe support.
Changes:
- Introduces a new
MediaCarouselcomponent for peeking 4:5 scroll-snap tiles (with mouse drag-to-swipe) and reuses it in feed + thread rendering. - Replaces existing image modal implementations with a new swipeable
MediaLightbox. - Extends
VideoPreviewwith afillmode for use inside fixed-aspect carousel tiles.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/VideoPreview.svelte | Adds fill prop to support tile-driven sizing in the new carousel. |
| src/components/NoteContent.svelte | Groups consecutive media URLs into a carousel run and swaps to MediaLightbox for fullscreen viewing. |
| src/components/MediaLightbox.svelte | New fullscreen lightbox with pointer-driven swipe/drag paging and updated chrome layout. |
| src/components/MediaCarousel.svelte | New shared gallery component supporting single-media shrink-wrap + multi-media peeking tiles with drag-to-swipe. |
| src/components/FoodstrFeedOptimized.svelte | Replaces bespoke feed carousel/modal with shared MediaCarousel + MediaLightbox. |
| package.json | Bumps app version. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <MediaCarousel | ||
| items={mediaUrls} | ||
| optimizeUrl={getOptimizedImageUrl} | ||
| onItemClick={(url, index) => openImageModal(url, mediaUrls, index)} | ||
| /> |
There was a problem hiding this comment.
Fixed in 9cc2d20 — onItemClick now filters mediaUrls to images and remaps the index before calling openImageModal, so the lightbox pager only ever contains image URLs. Videos keep playing inline in their tiles.
| $: count = images.length; | ||
| $: prevIndex = (index - 1 + count) % count; | ||
| $: nextIndex = (index + 1) % count; | ||
| $: multiple = count > 1; |
There was a problem hiding this comment.
The modulo math this referenced was removed when the lightbox was rebuilt on a native scroll-snap pager (no more prev/next index computation), but the underlying concern stands — 9cc2d20 adds a defensive guard that closes the lightbox if its image list empties while open.
| <button | ||
| type="button" | ||
| class="single-media-button" | ||
| on:click={() => onItemClick(items[0], 0)} | ||
| > |
There was a problem hiding this comment.
Fixed in 9cc2d20 — the single-image button now has aria-label="View image".
| <button | ||
| type="button" | ||
| class="tile-button" | ||
| on:click={() => onItemClick(url, index)} | ||
| > |
There was a problem hiding this comment.
Fixed in 9cc2d20 — tile buttons now carry aria-label="View image N of M".
| function isVideo(url: string): boolean { | ||
| try { | ||
| return VIDEO_EXTENSIONS.test(new URL(url).pathname); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } |
There was a problem hiding this comment.
Fixed in 9cc2d20 — MediaCarousel's isVideo() now also matches youtube.com / youtu.be / vimeo.com hosts, mirroring the feed extractor's classification, so platform links render through VideoPreview instead of being treated as images and hidden on load error. (Actually playing YouTube/Vimeo embeds is a pre-existing limitation of VideoPreview — behavior parity with the old carousel — and out of scope here.)
Posts with multiple inline images/videos now render as a horizontal swipeable gallery instead of a full-width one-at-a-time letterboxed carousel (feed) or a vertical stack (threads). New shared components: - MediaCarousel.svelte — galleries show uniform 4:5 tiles at ~72% width with 14px corners and a 6px gap, so the next tile peeks in from the right edge. Native scroll-snap paging with a "1 / 2" count capsule bottom-centre. Desktop adds hover chevrons and mouse drag-to-swipe (threshold-gated so plain clicks still open the lightbox; button-release and pointer-capture edge cases handled so drags can't stick). Single media items shrink-wrap to the photo's own aspect ratio — no letterbox/pillarbox bars. - swipeNav.ts — Svelte action giving the fullscreen lightbox the same swipe/drag paging with a translateX follow and snap-back. Surfaces: - FoodstrFeedOptimized: old inline carousel (markup, per-event scroll state, helpers, CSS) replaced by MediaCarousel; lightbox chrome (counter / close / arrows) moved out of the photo frame into the backdrop gutters and the photo now gets the full stage. - NoteContent: consecutive media URLs group into a gallery run that renders through MediaCarousel; lone media keeps inline rendering. Same lightbox treatment. - VideoPreview: new `fill` prop so videos can stretch into a gallery tile (cover preview, letterboxed playback so controls stay visible).
The fullscreen lightbox previously swapped the src on a single <img> when paging — no motion, and on slow connections the incoming image only started loading on arrival, which read as a laggy jump. New shared MediaLightbox component renders prev / current / next on a sliding three-pane track: a swipe drags the track 1:1 with the pointer and committing animates the neighbour into place. Side panes double as a preload of the adjacent images, so the next photo is usually decoded before it's needed. Navigation wraps; keyboard, counter, close, backdrop-click and desktop chevrons all live inside the component. Replaces the duplicated modal markup in FoodstrFeedOptimized and NoteContent, and the swipeNav action it superseded.
…matches the gallery The sliding-track lightbox translated a prev/current/next pane set by hand and got swipe direction wrong on touch. Replace it with the same mechanism the inline gallery uses: a native horizontal scroll-snap pager with one full-screen pane per image. The browser handles touch direction, momentum and snapping, so swiping in the lightbox feels identical to the feed carousel by construction. Mouse drag-to-swipe reuses the gallery's pointer rules; offscreen panes preload their images; navigation clamps at the ends (matching the gallery) instead of wrapping, and the chevrons hide at the edges accordingly.
677f807 to
a623a37
Compare
| <MediaCarousel | ||
| items={mediaUrls} | ||
| optimizeUrl={getOptimizedImageUrl} | ||
| onItemClick={(url, index) => openImageModal(url, mediaUrls, index)} | ||
| /> |
There was a problem hiding this comment.
Fixed in 9cc2d20 (same fix as the first-pass comment on this line): the lightbox receives an images-only list with the selected index computed against that list.
| import MediaCarousel from './MediaCarousel.svelte'; | ||
| import { processContentWithProfiles } from '$lib/contentProcessor'; | ||
| import MediaLightbox from './MediaLightbox.svelte'; | ||
| import { onMount, onDestroy } from 'svelte'; | ||
| import { browser } from '$app/environment'; |
There was a problem hiding this comment.
Fixed in 9cc2d20 — removed the unused onMount/onDestroy/browser imports.
…ction, a11y labels
- Feed: the lightbox only renders images, so onItemClick now passes an
images-only list (videos play inline in their tiles) with the index
remapped, instead of the mixed media list that produced broken panes
on image+video posts.
- MediaCarousel: video detection now also matches YouTube/Vimeo hosts,
aligning with the feed extractor's classification so platform links
aren't mistaken for images and hidden on load error.
- MediaCarousel: image tile buttons get aria-labels ("View image N of
M") so they're discoverable by screen readers.
- MediaLightbox: closes defensively if its image list empties while
open.
- NoteContent: drop unused onMount/onDestroy/browser imports left over
from the modal extraction.
Posts with multiple inline images/videos get a proper swipeable gallery, and the fullscreen lightbox is rebuilt as a swipeable pager with the same feel.
Gallery (feed + threads)
Multiple media items now render as a horizontal gallery of uniform 4:5 tiles at ~72% width with 14px corners and a 6px gap — the next tile peeks in from the right edge so it's obvious there's more to swipe, without any extra chrome. Paging is native scroll-snap; the indicator is a single "1 / 2" count capsule bottom-center (replaces the top-right counter + dot row).
FoodstrFeedOptimized): replaces the full-width one-at-a-time letterboxed carousel. The old per-event scroll-state map, six carousel helpers, and carousel CSS are deleted — all of it now lives in one shared component (MediaCarousel).NoteContent): consecutive media URLs (ignoring whitespace between them) group into a gallery run rendered through the same component. They previously stacked vertically. Lone media keeps its inline rendering.Single images: no more black bars
A single image's frame now shrink-wraps the photo's own aspect ratio instead of forcing a full-width letterboxed box. Very tall images cap at 70vh and narrow accordingly; very wide ones cap at column width. Small images render at natural size instead of upscaling.
Mouse drag-to-swipe
Native scroll containers only pan via touch/trackpad, so the gallery adds pointer-based dragging for mouse users: grab cursor, 1:1 drag, flick detection (a short decisive pull advances one tile), and release-snap. Two traps handled explicitly:
e.buttonsis checked on every move so a button released outside the gallery can't leave a stuck drag;pointercancel/lostpointercaptureare backstops.Fullscreen lightbox: rebuilt as a swipeable pager
The lightbox markup that was duplicated across the feed and
NoteContentis replaced by one sharedMediaLightboxcomponent, built on the same native scroll-snap mechanism as the inline gallery — one full-screen pane per image. The browser handles touch direction, momentum, and snapping, so swiping in the lightbox feels identical to the feed carousel by construction (and the incoming image slides in with the gesture instead of a src swap that reads as a laggy jump). Offscreen panes preload neighbouring images.Videos
VideoPreviewgains afillprop so videos stretch into gallery tiles (cover-cropped preview; letterboxed during playback so the native controls stay visible). Existing standalone usage unchanged.Test plan