Skip to content

Gallery & Collections

Gallery & Collections

Purpose

Document the redesigned gallery surface (Gallery v2) and the user-owned collections (albums) that back it. Gallery v2 replaces the home page / behind the gallery-v2 PostHog flag: flag on renders GalleryV2Shell, flag off keeps the legacy MainPage — same URL, straight A/B rollout. For the full-stack engineering view see Gallery v2 — Architecture.

Participants

  • Brisket renders the gallery shell, toolbar, grid, detail, search, filters, albums, and smart groups (features/gallery-v2/). Reads media via api.media.listAllMedia and collections via api.collections.*.
  • Sirloin serves ListAllMedia (cross-character feed, optionally scoped to a collection or a single character) and the collection CRUD / membership RPCs, and owns the media.collections + media.collection_media tables.

Cross-character media (ListAllMedia)

ListAllMedia reuses ListMedia’s filtering, carousel loading, example controls, and presigned-URL logic:

  • Empty character_id ⇒ no character scope (the whole user’s gallery).
  • Set character_id ⇒ the gallery’s “Characters” filter.
  • Set collection_id ⇒ results restricted to that collection’s members.
  • Each row carries optional character context (media_character_*) so the cross-character grid can label and filter without extra lookups.
  • Type and character filters pass server-side to ListAllMedia for both the main feed and album views (via resolveMediaType helper).

Paging uses skip_total with a limit+1 sentinel to detect further pages without a COUNT(*).

Collections (albums)

  • A collection is user-owned (media.collections, all queries filter user_id); membership rows live in media.collection_media with an added_at timestamp.
  • media_count and cover_path are computed as subqueries that exclude soft-deleted media; ListCollectionMediaIDs likewise joins media and filters deleted_at IS NULL, so the member list stays consistent with the shown count.
  • An empty (but valid) collection returns no media — callers must not fall back to listing all of the user’s media.
  • The cover preview is the most-recently-added non-deleted member.
  • Album ordering on the hub is local-only (persisted in localStorage): the backend has no album ordering.
  • Filtering inside albums: type and character filters apply server-side for collection-backed and scope-backed albums, client-side for pre-loaded albums (18+). Filters reset when navigating back to the albums hub.

Multi-select & bulk actions

  • Selection snapshots full GalleryItem data into selectedItemsMap so items survive filter changes that remove them from the current query.
  • Context-aware actions: archived view shows Download + Unarchive; elsewhere shows Add to album + Download + Archive.
  • Count badge on the select button reflects current selection across filters.
  • Downloads fire both the markMediaDownload tRPC mutation and the PostHog Generation Downloaded event for every item.

Download strategy

Platform-specific download handling via shareOrDownloadImages:

  • Embedded browsers (FB/Instagram on Android): toast, no download.
  • Single image on mobile (non-Chrome-iOS): navigator.share (save to photos).
  • iOS Safari bulk images: redirect to /download page (tap-and-hold).
  • iOS Safari videos: blob auto-download.
  • Chrome iOS: application/octet-stream blob trick.
  • Desktop / Android: staggered <a download> clicks.

The detail view pre-warms files from cache via useShareableDownload to stay within iOS’s transient user activation window.

Invariants

  • Ownership is enforced server-side on every collection read/mutation and on add-to-collection (media not owned by the user is rejected).
  • Draft characters are excluded from the gallery’s search and character filter.
  • Deleting a collection soft-deletes the collection and hard-deletes its membership rows in one transaction.
  • PostHog Generation Downloaded fires for every download (single + bulk).

Error paths

  • Invalid collection / not owned ⇒ collection not found.
  • Adding media not owned by the user ⇒ rejected before any membership write.
  • Cover presign failures degrade to “no cover” (the collection still lists).
  • Embedded browser download ⇒ toast “open in a regular browser”.

Known follow-ups

  • Collection media is returned in created_at order, while the cover is the most-recently-added member, so the cover and first grid tile can differ.
  • 18+ gating is session-local and must bind to the real entitlement / age-verification before ship (requires sign-off).
  • Per-slide carousel editing needs image-pipeline support; today “Edit” on a carousel edits the cover and yields a standalone photo.

Verification

  • / on a PR preview: grid, detail, search, filters, albums CRUD, add-to-album, smart groups, favorites/archive.
  • Multi-select download across carousels + videos + photos on desktop and mobile.
  • Create an album, add/remove media, filter inside it, delete media in it, and confirm the count and cover stay consistent.