Skip to content

Gallery & Collections

Document the redesigned gallery surface (Gallery v2) and the user-owned collections (albums) that back it. The home page / always renders GalleryV2Shell; there is no legacy Gallery route or rollout flag branch. For the full-stack engineering view see Gallery v2 — Architecture.

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

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

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

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.

WebKit browsers often cancel the markMediaDownload request while the file saves. Brisket therefore calls Sirloin’s SetMediaDownloaded without the request’s abort signal, so the mark still lands after the browser cancels.

  • 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).
  • 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”.
  • 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.
  • / 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.