Skip to content

feat(media): swipeable peeking-tile gallery for multi-image posts#425

Open
dmnyc wants to merge 4 commits into
zapcooking:mainfrom
dmnyc:feat/media-gallery-carousel
Open

feat(media): swipeable peeking-tile gallery for multi-image posts#425
dmnyc wants to merge 4 commits into
zapcooking:mainfrom
dmnyc:feat/media-gallery-carousel

Conversation

@dmnyc

@dmnyc dmnyc commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

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).

  • Feed (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).
  • Threads (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:

  • Pointer capture and drag styling only engage after an 8px horizontal-dominant threshold — capturing on bare mousedown retargets the synthesized click and breaks tap-to-open.
  • e.buttons is checked on every move so a button released outside the gallery can't leave a stuck drag; pointercancel/lostpointercapture are backstops.

Fullscreen lightbox: rebuilt as a swipeable pager

The lightbox markup that was duplicated across the feed and NoteContent is replaced by one shared MediaLightbox component, 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.

  • Chrome lives outside the photo frame: counter top-left, close top-right, prev/next chevrons pinned to the screen edges in the backdrop gutters; the stage is padded so the photo never sits under a control. Chevrons are desktop-only (touch users swipe) and hide at the ends.
  • Paging clamps at the first/last image, matching the inline gallery (no wrap-around).
  • Mouse drag-to-swipe reuses the gallery's pointer rules; keyboard ←/→ and Escape, backdrop-click-to-close, and tap-to-open all carried over.

Videos

VideoPreview gains a fill prop so videos stretch into gallery tiles (cover-cropped preview; letterboxed during playback so the native controls stay visible). Existing standalone usage unchanged.

Test plan

  • Feed: post with 2+ images shows peeking tiles, count badge updates while swiping
  • Feed: single-image post has no black bars (portrait + landscape)
  • Thread view: multi-image note renders the same gallery
  • Desktop: drag gallery with mouse; flick advances; click (no drag) still opens lightbox; no stuck-drag after releasing outside
  • Lightbox (mobile): swipe left goes left, swipe right goes right, with real slide motion
  • Lightbox: arrows/keyboard/click-outside still work; chrome never overlaps the photo
  • Videos in a gallery tile: autoplay preview fills tile, tap to play shows controls

@dmnyc dmnyc force-pushed the feat/media-gallery-carousel branch from 656fcdc to bef50b7 Compare June 11, 2026 02:38
@spe1020 spe1020 requested a review from Copilot June 11, 2026 02:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MediaCarousel component 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 VideoPreview with a fill mode 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.

Comment on lines +5339 to +5343
<MediaCarousel
items={mediaUrls}
optimizeUrl={getOptimizedImageUrl}
onItemClick={(url, index) => openImageModal(url, mediaUrls, index)}
/>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9cc2d20onItemClick 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.

Comment on lines +20 to +23
$: count = images.length;
$: prevIndex = (index - 1 + count) % count;
$: nextIndex = (index + 1) % count;
$: multiple = count > 1;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +170 to +174
<button
type="button"
class="single-media-button"
on:click={() => onItemClick(items[0], 0)}
>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9cc2d20 — the single-image button now has aria-label="View image".

Comment on lines +207 to +211
<button
type="button"
class="tile-button"
on:click={() => onItemClick(url, index)}
>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9cc2d20 — tile buttons now carry aria-label="View image N of M".

Comment on lines +28 to +34
function isVideo(url: string): boolean {
try {
return VIDEO_EXTENSIONS.test(new URL(url).pathname);
} catch {
return false;
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

dmnyc added 3 commits June 10, 2026 23:18
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.
@dmnyc dmnyc force-pushed the feat/media-gallery-carousel branch from 677f807 to a623a37 Compare June 11, 2026 03:19
@dmnyc dmnyc requested a review from spe1020 June 11, 2026 14:40
@spe1020 spe1020 requested a review from Copilot June 11, 2026 15:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment on lines +5339 to +5343
<MediaCarousel
items={mediaUrls}
optimizeUrl={getOptimizedImageUrl}
onItemClick={(url, index) => openImageModal(url, mediaUrls, index)}
/>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/components/NoteContent.svelte Outdated
Comment on lines 10 to 14
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';

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants