diff --git a/common/messages/de.json b/common/messages/de.json
index 01646240..a4e56494 100644
--- a/common/messages/de.json
+++ b/common/messages/de.json
@@ -871,6 +871,7 @@
"profile.comments.update_error": "Aktualisierung der Empfehlungseinstellungen fehlgeschlagen",
"profile.connect.direct_messaging_disabled": "{user} hat Direktnachrichten deaktiviert",
"profile.connect.how_this_works": "Wie es funktioniert",
+ "profile.connect.message_name": "{name} schreiben",
"profile.connect.interest_indicators_disabled": "{user} hat Interessenssignale deaktiviert",
"profile.connect.load_preferences_failed": "Fehler beim Laden der Einstellungen",
"profile.connect.mutual": "Gegenseitig",
@@ -1215,6 +1216,8 @@
"profile.optional.want_kids": "Ich möchte Kinder haben",
"profile.optional.work": "Arbeit",
"profile.personality": "Persönlichkeit",
+ "profile.photos": "Fotos",
+ "profile.photos.scroll_sideways": "Seitwärts scrollen",
"profile.orientation": "Orientierung",
"profile.orientation.aceflux": "Aceflux",
"profile.orientation.akioromantic": "Akioromantisch",
diff --git a/common/messages/fr.json b/common/messages/fr.json
index 1a0e9f98..52428752 100644
--- a/common/messages/fr.json
+++ b/common/messages/fr.json
@@ -870,6 +870,7 @@
"profile.comments.update_error": "Échec de la mise à jour des paramètres de recommandation",
"profile.connect.direct_messaging_disabled": "{user} a désactivé la messagerie directe",
"profile.connect.how_this_works": "Comment ça fonctionne",
+ "profile.connect.message_name": "Écrire à {name}",
"profile.connect.interest_indicators_disabled": "{user} a désactivé les indicateurs d'intérêt",
"profile.connect.load_preferences_failed": "Échec du chargement des préférences",
"profile.connect.mutual": "Mutuel",
@@ -1214,6 +1215,8 @@
"profile.optional.want_kids": "Je souhaite avoir des enfants",
"profile.optional.work": "Domaine de travail",
"profile.personality": "Personnalité",
+ "profile.photos": "Photos",
+ "profile.photos.scroll_sideways": "Faire défiler",
"profile.orientation": "Orientation",
"profile.orientation.aceflux": "Aceflux",
"profile.orientation.akioromantic": "Akioromantique",
diff --git a/tests/e2e/utils/seed-showcase.ts b/tests/e2e/utils/seed-showcase.ts
index d63dd350..cc942dd4 100644
--- a/tests/e2e/utils/seed-showcase.ts
+++ b/tests/e2e/utils/seed-showcase.ts
@@ -58,7 +58,7 @@ const DEFAULT_AVATAR = '/images/default-avatar.png'
* Missing portraits are not fatal: the profile still seeds, it just wears the default avatar.
*
* `pinned_url` and `photo_urls` are disjoint: the pinned photo must NOT also appear in `photo_urls`.
- * `ProfileGallery` renders `buildArray(pinned_url, photo_urls)` without deduping, so a shared entry
+ * `useProfilePhotos` renders `buildArray(pinned_url, photo_urls)` without deduping, so a shared entry
* shows up twice. (The photo editor wraps the same call in `uniq`, which masks it while editing.)
*/
function resolvePhotos(profile: ShowcaseProfile) {
diff --git a/web/components/profile-about.tsx b/web/components/profile-about.tsx
index 596dcb89..a98beac5 100644
--- a/web/components/profile-about.tsx
+++ b/web/components/profile-about.tsx
@@ -30,6 +30,7 @@ import {Col} from 'web/components/layout/col'
import {CustomLink} from 'web/components/links'
import {UserHandles} from 'web/components/user/user-handles'
import {useChoicesContext} from 'web/hooks/use-choices'
+import {useMeasurementSystem} from 'web/hooks/use-measurement-system'
import {useLocale, useT} from 'web/lib/locale'
import {getSeekingConnectionText} from 'web/lib/profile/seeking'
import {convertRace} from 'web/lib/util/convert-types'
@@ -64,7 +65,9 @@ export default function ProfileAbout(props: {
5. Languages — practical.
6. Identity nuance — gender and orientation only render when the member wrote something of
their own about them, so they are elaboration, not a demographic checkbox to scan.
- 7. Background, then activity metadata. */}
+ 7. Background, then height, then activity metadata. Height sits down here rather than in
+ the hero line: it is a number people filter on, not part of how anyone introduces
+ themselves, and beside the name it was taking the weight of one. */}
{!omitConnectionGoals && }
@@ -81,6 +84,7 @@ export default function ProfileAbout(props: {
+
{!isCurrentUser && (
<>
@@ -520,6 +524,21 @@ function Ethnicity(props: {profile: Profile}) {
return
}
+function Height(props: {profile: Profile}) {
+ const t = useT()
+ const {profile} = props
+ const {measurementSystem} = useMeasurementSystem()
+
+ if (profile.height_in_inches == null) return null
+
+ return (
+
+ )
+}
+
function Smoker(props: {profile: Profile}) {
const t = useT()
const {profile} = props
diff --git a/web/components/profile-comment-section.tsx b/web/components/profile-comment-section.tsx
index 7806b4b7..0d3714bb 100644
--- a/web/components/profile-comment-section.tsx
+++ b/web/components/profile-comment-section.tsx
@@ -66,11 +66,22 @@ export const ProfileCommentSection = (props: {
trackingLocation={'contract page'}
/>
) : (
- /* One compact row, not a panel. This is an invitation, and as a 170px dashed box it
- outweighed the endorsements it was asking for — and a dashed border reads as a drop
- zone, which is the wrong metaphor. The only card left in this region is the
- connection-signal one, which actually holds controls. */
+ /* One compact row, not a panel. This is an invitation, and as a 170px box it
+ outweighed the endorsements it was asking for. The only card left in this region
+ is the connection-signal one, which actually holds controls. */
+ {/* Dashed, unfilled, in body ink rather than the accent: nobody arrives here
+ meaning to write one, so the control should be findable when the thought
+ occurs and invisible until then. A solid accent pill asked for the click
+ harder than the endorsements below it asked to be read. */}
+ {!isCurrentUser && (
+
+ )}
{isCurrentUser
? t(
@@ -79,17 +90,10 @@ export const ProfileCommentSection = (props: {
)
: t(
'profile.comments.other_user_hint',
- 'If you know them, write something nice that adds to their profile.',
+ 'Know {name}? A few honest sentences help strangers trust him.',
+ {name: onUser.name},
)}
- {!isCurrentUser && (
-
- )}
)}
>
diff --git a/web/components/profile/profile-gallery.tsx b/web/components/profile/profile-gallery.tsx
deleted file mode 100644
index 8d9dd3f3..00000000
--- a/web/components/profile/profile-gallery.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import clsx from 'clsx'
-import {Profile} from 'common/profiles/profile'
-import {buildArray} from 'common/util/array'
-import Image from 'next/image'
-import {useState} from 'react'
-import {Col} from 'web/components/layout/col'
-import {MediaModal} from 'web/components/media-modal'
-import {SignUpButton} from 'web/components/nav/sidebar'
-import {useUser} from 'web/hooks/use-user'
-import {isVideo} from 'web/lib/firebase/storage'
-import {useT} from 'web/lib/locale'
-
-/**
- * Square photo gallery for the profile hero: one main tile plus a thumbnail strip.
- *
- * Photos are the second thing a visitor checks, so they sit at the top — but the tile is capped at a
- * modest size on purpose. A full-bleed hero image would push the bio, which ranks right behind them,
- * off the first screen.
- */
-const MAIN_SIZE = 300
-const THUMB_SIZE = 64
-
-export default function ProfileGallery(props: {profile: Profile; className?: string}) {
- const {profile, className} = props
- const t = useT()
- const currentUser = useUser()
-
- const urls = buildArray(profile.pinned_url, profile.photo_urls)
- const [selected, setSelected] = useState(0)
- const [lightboxOpen, setLightboxOpen] = useState(false)
-
- if (urls.length === 0) return null
-
- // Signed-out visitors on a members-only profile see the pinned photo and a locked count.
- const isLocked = !currentUser && profile.visibility !== 'public'
- const visibleUrls = isLocked ? urls.slice(0, 1) : urls
-
- const selectedUrl = visibleUrls[Math.min(selected, visibleUrls.length - 1)]
-
- // Every photo gets a thumbnail — they wrap onto as many rows as it takes. The only "+N" left is the
- // locked one, where the count is all a signed-out visitor is allowed to know.
- const lockedCount = isLocked ? urls.length - 1 : 0
-
- return (
-
- )}
-
- {/* The whole set goes to the modal, not just the tile that was clicked, so it can page through
- them and show each one's description. Descriptions live only there — under the grid they
- changed height as you moved between thumbnails and shoved the hero around. */}
- }
- open={lightboxOpen}
- setOpen={setLightboxOpen}
- />
-
- )
-}
-
-function MediaTile(props: {
- url: string
- priority?: boolean
- onClick?: () => void
- className?: string
-}) {
- const {url, priority, onClick, className} = props
-
- if (isVideo(url)) {
- return (
-
- )
- }
-
- return (
-
- )
-}
diff --git a/web/components/profile/profile-hero-photo.tsx b/web/components/profile/profile-hero-photo.tsx
new file mode 100644
index 00000000..e12ea089
--- /dev/null
+++ b/web/components/profile/profile-hero-photo.tsx
@@ -0,0 +1,57 @@
+import clsx from 'clsx'
+import {clamp} from 'lodash'
+import {useState} from 'react'
+import {useSafeLayoutEffect} from 'web/hooks/use-safe-layout-effect'
+
+import {MediaTile} from './profile-photos'
+
+const MIN_SIZE = 200
+/** A square as tall as a long text column would leave nothing beside it. */
+const MAX_WIDTH_RATIO = 0.46
+const FALLBACK_SIZE = 300
+/**
+ * Deadband on the measured size. Widening the photo narrows the text, which can make the text
+ * taller, which widens the photo again — so a threshold too small to swallow one line of text lets
+ * the two chase each other forever. Anything under this is left alone.
+ */
+const SETTLE_PX = 8
+
+/**
+ * The single hero photo, square, sized to the height of the text beside it.
+ *
+ * Square rather than the photo's own shape: it makes the band the same at every profile regardless
+ * of what people upload, and the crop is centred, which is where faces are.
+ */
+export default function ProfileHeroPhoto(props: {
+ url: string
+ /** Measured height of the text column — what the photo is matched to. */
+ textHeight: number | undefined
+ /** Measured width of the whole hero row, so a tall text column cannot crowd it out. */
+ bandWidth: number | undefined
+ onClick?: () => void
+ className?: string
+}) {
+ const {url, textHeight, bandWidth, onClick, className} = props
+
+ const [size, setSize] = useState(null)
+
+ useSafeLayoutEffect(() => {
+ if (!textHeight || !bandWidth) return
+ const target = clamp(textHeight, MIN_SIZE, bandWidth * MAX_WIDTH_RATIO)
+ setSize((prev) => (prev != null && Math.abs(prev - target) < SETTLE_PX ? prev : target))
+ }, [textHeight, bandWidth])
+
+ return (
+
+
+
+ )
+}
diff --git a/web/components/profile/profile-hero.tsx b/web/components/profile/profile-hero.tsx
index aa92d776..cd8fac66 100644
--- a/web/components/profile/profile-hero.tsx
+++ b/web/components/profile/profile-hero.tsx
@@ -3,12 +3,14 @@ import clsx from 'clsx'
import {Profile} from 'common/profiles/profile'
import {User, UserActivity} from 'common/user'
import Link from 'next/link'
-import React from 'react'
+import React, {useRef} from 'react'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
-import ProfileGallery from 'web/components/profile/profile-gallery'
+import ProfileHeroPhoto from 'web/components/profile/profile-hero-photo'
+import {useProfilePhotos} from 'web/components/profile/profile-photos'
import {ProfileConnectionGoals} from 'web/components/profile-about'
import {linkClass} from 'web/components/widgets/site-link'
+import {useElementSize} from 'web/hooks/use-element-size'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
import {capitalizePure} from 'web/lib/util/time'
@@ -23,6 +25,9 @@ import ProfilePrimaryInfo from './profile-primary-info'
*
* No call to action here on purpose: reaching out belongs to the top bar (always reachable) and the
* Connect section at the end, not to the moment someone is still deciding who they are looking at.
+ *
+ * One photo shares the band, matched to the height of the text beside it; the rest run as a
+ * carousel underneath, where they get room to be seen.
*/
export default function ProfileHero(props: {
user: User
@@ -36,6 +41,14 @@ export default function ProfileHero(props: {
const isCurrentUser = currentUser?.id === user.id
const t = useT()
+ const photos = useProfilePhotos(profile)
+
+ // The photo is matched to the height of the text, so both are measured rather than guessed.
+ const bandRef = useRef(null)
+ const textRef = useRef(null)
+ const bandSize = useElementSize(bandRef)
+ const textSize = useElementSize(textRef)
+
const name = (
)}
-
-
+ {/* The text is capped rather than left to fill the row, and the photo follows it directly:
+ pinned to the far margin it read as a second column with a gutter of dead space between
+ the two, and the tagline stretched to a measure nobody wants to read. Whatever is left
+ over on a wide screen sits to the right of the photo. */}
+ {/* `items-start`, never `stretch`: the photo takes its size from the text column, so stretching
+ that column to the row height would feed the photo its own height back. A short profile
+ then kept whatever size the photo happened to start at instead of shrinking to the text. */}
+
+ {/* Place, age, height, gender read as an eyebrow above the name rather than a line under
+ it: they are how you file someone, not what you call them, and above the display type
+ they are read once and passed over instead of competing with the tagline. */}
+ {/* The eyebrow needs air under it: at display size the name's own line box provides
+ almost none, and 8px read as the two lines being one block. */}
+
+
{simpleView ? (
@@ -82,30 +118,8 @@ export default function ProfileHero(props: {
name
)}
-
- {/* Outlined rather than filled, squared rather than pill: five filled pills read as five
- buttons. These are labels, and the eye should pass over them on the way to the tagline. */}
- {profile.keywords && profile.keywords.length > 0 && (
-
- {profile.keywords.map(capitalizePure).map((tag, i) => (
-
- {tag.trim()}
-
- ))}
-
- )}
-
{/* The tagline is the one thing on this page written to be read as a voice, so it gets the
serif italic at display size instead of a quoted aside pinned behind a rule. */}
{profile.headline && (
@@ -122,9 +136,34 @@ export default function ProfileHero(props: {
)}
+ {/* Outlined rather than filled, squared rather than pill: five filled pills read as five
+ buttons. These are labels, and the eye should land on them last, after the tagline.
+ Same tracking as the eyebrow they echo, and a border kept under the eyebrow's weight so
+ a row of them does not out-shout the line that opens the block. */}
+ {profile.keywords && profile.keywords.length > 0 && (
+
+ {profile.keywords.map(capitalizePure).map((tag, i) => (
+
+ {tag.trim()}
+
+ ))}
+
+ )}
+
+
+ {/* The rest of the photos, sideways under the bio rather than as thumbnails beside the
+ hero: here they are large enough to be looked at instead of counted. */}
+ }
+ lockedCount={photos.lockedCount}
+ indexOffset={1}
+ onSelect={photos.openAt}
+ />
+ {photos.lightbox}
+
{/* Pinned beside the prose, and scrollable within itself — see ScrollPanel for the cues that
make the second half of that obvious. Once wide enough for two readable columns it splits,
diff --git a/web/components/profile/profile-photo-carousel.tsx b/web/components/profile/profile-photo-carousel.tsx
new file mode 100644
index 00000000..183bedb6
--- /dev/null
+++ b/web/components/profile/profile-photo-carousel.tsx
@@ -0,0 +1,149 @@
+import clsx from 'clsx'
+import {clamp} from 'lodash'
+import {useCallback, useEffect, useRef, useState} from 'react'
+import {Col} from 'web/components/layout/col'
+import {Row} from 'web/components/layout/row'
+import {SignUpButton} from 'web/components/nav/sidebar'
+import {useT} from 'web/lib/locale'
+
+import {MediaTile} from './profile-photos'
+
+const CARD_HEIGHT_CLASS = 'h-[220px] md:h-[300px]'
+/** Same floor as the hero: a sliver of a photo is worse than a centred crop of it. */
+const MIN_ASPECT = 4 / 5
+/** One panorama should not be the whole strip. */
+const MAX_ASPECT = 16 / 9
+
+/**
+ * The rest of the photos, as one sideways strip under the bio.
+ *
+ * A thumbnail rail next to the hero made every photo but one a 64px afterthought. Down here each
+ * one is shown at a size worth looking at, at a shared height so the row reads as a single line,
+ * and with its description as a caption — the one place descriptions are legible without opening
+ * anything.
+ */
+export default function ProfilePhotoCarousel(props: {
+ urls: string[]
+ descriptions?: Record
+ lockedCount?: number
+ /** Index within the full photo set, so the lightbox opens on the photo that was clicked. */
+ indexOffset?: number
+ onSelect?: (index: number) => void
+ className?: string
+}) {
+ const {urls, descriptions, lockedCount = 0, indexOffset = 0, onSelect, className} = props
+ const t = useT()
+
+ const scrollRef = useRef(null)
+ // Kept measured while the hint above the strip is commented out, so re-enabling it is one edit.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [hasMore, setHasMore] = useState(false)
+
+ const measure = useCallback(() => {
+ const el = scrollRef.current
+ if (!el) return
+ // 4px of slack keeps the hint from sticking on at the very end of a sub-pixel layout.
+ setHasMore(el.scrollWidth - el.clientWidth - el.scrollLeft > 4)
+ }, [])
+
+ useEffect(() => {
+ const el = scrollRef.current
+ if (!el) return
+ measure()
+ el.addEventListener('scroll', measure, {passive: true})
+ const observer = new ResizeObserver(measure)
+ observer.observe(el)
+ return () => {
+ el.removeEventListener('scroll', measure)
+ observer.disconnect()
+ }
+ }, [measure, urls.length])
+
+ if (urls.length === 0 && lockedCount === 0) return null
+
+ return (
+
*/}
+
+
+ {/* Two overrides here, both load-bearing for scrolling the *page* with the cursor over the
+ strip. `overflow-y-hidden`: a non-visible `overflow-x` makes the browser compute
+ `overflow-y` as auto too, so a stray pixel of vertical overflow would make this a vertical
+ scroller. `!overscroll-y-auto`: `.scrollbar-visible` sets `overscroll-behavior: contain`,
+ which is right for ScrollPanel and wrong here — it stops the wheel chaining to the page.
+ Contain is kept on the x axis, where it prevents a sideways swipe going back a page. */}
+
+ )}
+
+ )
+}
diff --git a/web/components/profile/profile-photos.tsx b/web/components/profile/profile-photos.tsx
new file mode 100644
index 00000000..d8a56715
--- /dev/null
+++ b/web/components/profile/profile-photos.tsx
@@ -0,0 +1,105 @@
+import clsx from 'clsx'
+import {Profile} from 'common/profiles/profile'
+import {buildArray} from 'common/util/array'
+import Image from 'next/image'
+import {useState} from 'react'
+import {MediaModal} from 'web/components/media-modal'
+import {useUser} from 'web/hooks/use-user'
+import {isVideo} from 'web/lib/firebase/storage'
+
+/**
+ * Shared state for the two places a profile's photos appear: the hero portrait and the carousel
+ * under the bio.
+ *
+ * They are one set, not two — clicking either opens the same lightbox at the right index — so the
+ * urls and the lightbox live here rather than being duplicated on both sides.
+ */
+export function useProfilePhotos(profile: Profile) {
+ const currentUser = useUser()
+ const [index, setIndex] = useState(0)
+ const [lightboxOpen, setLightboxOpen] = useState(false)
+
+ const urls = buildArray(profile.pinned_url, profile.photo_urls)
+
+ // Signed-out visitors on a members-only profile see the pinned photo and a locked count.
+ const isLocked = !currentUser && profile.visibility !== 'public'
+ const visibleUrls = isLocked ? urls.slice(0, 1) : urls
+ const lockedCount = isLocked ? urls.length - 1 : 0
+
+ const safeIndex = Math.min(index, Math.max(0, visibleUrls.length - 1))
+
+ return {
+ urls,
+ visibleUrls,
+ isLocked,
+ lockedCount,
+ /** Opens the lightbox on one photo of the set — same set from either component. */
+ openAt: (i: number) => {
+ setIndex(i)
+ setLightboxOpen(true)
+ },
+ lightbox: (
+ /* The whole set goes to the modal, not just the tile that was clicked, so it can page through
+ them and show each one's description. */
+ }
+ open={lightboxOpen}
+ setOpen={setLightboxOpen}
+ />
+ ),
+ }
+}
+
+/**
+ * A photo filling its box, reporting its intrinsic aspect ratio once decoded.
+ *
+ * Both callers size the box from that ratio, so the box starts at a fallback shape and settles when
+ * the ratio arrives. `naturalWidth` is the intrinsic size, unaffected by the CSS stretching it here.
+ */
+export function MediaTile(props: {
+ url: string
+ priority?: boolean
+ sizes?: string
+ onAspect?: (aspect: number) => void
+ onClick?: () => void
+ className?: string
+}) {
+ const {url, priority, sizes, onAspect, onClick, className} = props
+
+ if (isVideo(url)) {
+ return (
+