Refactor profile photo components: replace ProfileGallery with modular ProfileHeroPhoto, ProfilePhotoCarousel, and shared useProfilePhotos; enhance layout, improve accessibility, and remove redundant implementations.

This commit is contained in:
MartinBraquet
2026-07-28 18:28:23 +02:00
parent adeed6a802
commit 766de00d7c
14 changed files with 499 additions and 200 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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 && <SeekingAndRelationship profile={profile} />}
<OccupationAndWork profile={profile} />
<Education profile={profile} />
@@ -81,6 +84,7 @@ export default function ProfileAbout(props: {
<Orientation profile={profile} />
<Ethnicity profile={profile} />
<RaisedIn profile={profile} />
<Height profile={profile} />
{!isCurrentUser && (
<>
<LastOnline lastOnlineTime={userActivity?.last_online_time} />
@@ -520,6 +524,21 @@ function Ethnicity(props: {profile: Profile}) {
return <AboutRow title={t('profile.ethnicity', 'Ethnicity')} text={text} />
}
function Height(props: {profile: Profile}) {
const t = useT()
const {profile} = props
const {measurementSystem} = useMeasurementSystem()
if (profile.height_in_inches == null) return null
return (
<AboutRow
title={t('profile.optional.height', 'Height')}
text={formatProfileValue('height_in_inches', profile.height_in_inches, measurementSystem, t)}
/>
)
}
function Smoker(props: {profile: Profile}) {
const t = useT()
const {profile} = props

View File

@@ -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. */
<Row className="mb-6 flex-wrap items-center gap-x-4 gap-y-2">
{/* 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 && (
<button
onClick={() => setShowCommentInput(true)}
className="border-canvas-300 text-ink-500 hover:border-canvas-400 hover:text-ink-700 w-fit shrink-0 rounded-[10px] border border-dashed px-5 py-2.5 text-sm transition-colors"
>
{t('profile.comments.write_button', 'Write public endorsement')}
</button>
)}
<div className="text-ink-500 text-sm">
{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},
)}
</div>
{!isCurrentUser && (
<button
onClick={() => setShowCommentInput(true)}
className="border-canvas-300 text-primary-700 hover:border-primary-400 hover:bg-primary-50 w-fit shrink-0 rounded-full border px-4 py-2 text-sm transition-colors"
>
{t('profile.comments.write_button', 'Write public endorsement')}
</button>
)}
</Row>
)}
</>

View File

@@ -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 (
<Col className={clsx('flex-none gap-3', className)} style={{width: MAIN_SIZE}}>
<div
className="border-canvas-300 relative aspect-square w-full overflow-hidden rounded-[4px] border"
style={{maxWidth: MAIN_SIZE}}
>
<MediaTile
url={selectedUrl}
priority
onClick={() => !isLocked && setLightboxOpen(true)}
className={isLocked ? 'cursor-default' : 'cursor-pointer'}
/>
</div>
{(visibleUrls.length > 1 || lockedCount > 0) && (
<div className="flex flex-wrap gap-2">
{!isLocked &&
visibleUrls.map((url, i) => (
<button
key={url}
onClick={() => setSelected(i)}
aria-label={t('profile.gallery.show_photo', 'Show photo {number}', {number: i + 1})}
className={clsx(
'overflow-hidden rounded-[3px] border transition-colors',
i === selected
? 'border-primary-500'
: 'border-canvas-300 hover:border-primary-400',
)}
style={{width: THUMB_SIZE, height: THUMB_SIZE}}
>
<MediaTile url={url} />
</button>
))}
{lockedCount > 0 && (
<div
className="bg-canvas-100 border-canvas-300 text-ink-500 flex items-center justify-center rounded-[3px] border border-dashed text-sm"
style={{width: THUMB_SIZE, height: THUMB_SIZE}}
>
<SignUpButton
text={`+${lockedCount}`}
size="xs"
color="none"
className="dark:text-ink-500 hover:text-primary-500"
/>
</div>
)}
</div>
)}
{/* 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. */}
<MediaModal
urls={visibleUrls}
index={Math.min(selected, visibleUrls.length - 1)}
setIndex={setSelected}
descriptions={profile.image_descriptions as Record<string, string>}
open={lightboxOpen}
setOpen={setLightboxOpen}
/>
</Col>
)
}
function MediaTile(props: {
url: string
priority?: boolean
onClick?: () => void
className?: string
}) {
const {url, priority, onClick, className} = props
if (isVideo(url)) {
return (
<video
src={url}
className={clsx('h-full w-full object-cover', className)}
autoPlay
muted
loop
playsInline
onClick={onClick}
/>
)
}
return (
<Image
priority={priority}
src={url}
height={MAIN_SIZE}
width={MAIN_SIZE}
sizes={`${MAIN_SIZE}px`}
alt=""
className={clsx('h-full w-full object-cover', className)}
onClick={onClick}
/>
)
}

View File

@@ -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<number | null>(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 (
<div
className={clsx(
'border-canvas-300 relative aspect-square w-full flex-none overflow-hidden rounded-xl border',
'md:w-[var(--hero-photo-size)] md:self-start',
onClick && 'cursor-pointer',
className,
)}
style={{['--hero-photo-size' as string]: `${size ?? FALLBACK_SIZE}px`}}
>
<MediaTile url={url} priority sizes="(max-width: 768px) 100vw, 40vw" onClick={onClick} />
</div>
)
}

View File

@@ -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<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(null)
const bandSize = useElementSize(bandRef)
const textSize = useElementSize(textRef)
const name = (
<span
className="font-heading text-ink-1000 font-normal"
@@ -68,11 +81,34 @@ export default function ProfileHero(props: {
</Notice>
)}
<div className="flex flex-col gap-10 md:flex-row md:items-start md:gap-16">
<ProfileGallery profile={profile} />
{/* 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. */}
<div ref={bandRef} className="flex flex-col gap-10 md:flex-row md:items-start md:gap-[72px]">
{photos.visibleUrls.length > 0 && (
<ProfileHeroPhoto
// Right of the text on desktop, above it when stacked: a photo is what you look at
// first either way, and on a wide screen the name should still open the line.
className="md:order-last"
url={photos.visibleUrls[0]}
textHeight={textSize?.height}
bandWidth={bandSize?.width}
onClick={photos.isLocked ? undefined : () => photos.openAt(0)}
/>
)}
<Col className="min-w-0 flex-1 gap-7">
<Col className="gap-3">
<Col ref={textRef} className="min-w-0 flex-1 gap-7 md:max-w-3xl">
{/* 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. */}
<Col className="gap-4">
<ProfilePrimaryInfo profile={profile} eyebrow />
<div data-testid="profile-display-name-age">
{simpleView ? (
<Link className={linkClass} href={`/${user.username}`}>
@@ -82,30 +118,8 @@ export default function ProfileHero(props: {
name
)}
</div>
<ProfilePrimaryInfo profile={profile} />
</Col>
{/* 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 && (
<Row className="max-w-3xl flex-wrap gap-2" data-testid="profile-keywords">
{profile.keywords.map(capitalizePure).map((tag, i) => (
<span
key={i}
className="border-canvas-300 text-primary-800 font-dm-sans rounded-[3px] border uppercase"
style={{
padding: '6px 11px',
fontSize: '11px',
fontWeight: '400',
letterSpacing: '0.13em',
}}
>
{tag.trim()}
</span>
))}
</Row>
)}
{/* 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: {
</div>
)}
{/* 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 && (
<Row className="max-w-3xl flex-wrap gap-2" data-testid="profile-keywords">
{profile.keywords.map(capitalizePure).map((tag, i) => (
<span
key={i}
className="border-canvas-300/70 text-primary-800 font-dm-sans rounded-[3px] border uppercase"
style={{
padding: '6px 11px',
fontSize: '11px',
fontWeight: '400',
letterSpacing: '0.16em',
}}
>
{tag.trim()}
</span>
))}
</Row>
)}
<ProfileConnectionGoals profile={profile} />
</Col>
</div>
{photos.lightbox}
</Col>
)
}

View File

@@ -11,6 +11,8 @@ import {SignUpButton} from 'web/components/nav/sidebar'
import {ConnectActions} from 'web/components/profile/connect-actions'
import {ProfileHeaderActions} from 'web/components/profile/profile-header'
import ProfileHero from 'web/components/profile/profile-hero'
import ProfilePhotoCarousel from 'web/components/profile/profile-photo-carousel'
import {useProfilePhotos} from 'web/components/profile/profile-photos'
import ProfileAbout, {
hasAccessibility,
hasBigFive,
@@ -461,6 +463,7 @@ function ProfileContent(props: {
const isCurrentUser = currentUser?.id === user.id
const t = useT()
const photos = useProfilePhotos(profile)
const {answeredQuestions} = useCompatibilityQuestionGroups(user.id)
const showCompatibilityPrompts = currentUser && (isCurrentUser || answeredQuestions.length > 0)
@@ -479,7 +482,7 @@ function ProfileContent(props: {
className="mt-10 flex flex-col gap-14 lg:grid lg:grid-cols-[minmax(0,700px)_minmax(300px,1fr)] lg:items-start lg:gap-x-12 lg:gap-y-14"
data-testid="profile-content"
>
<div className="min-w-0 lg:col-start-1 lg:row-start-1">
<Col className="min-w-0 gap-14 lg:col-start-1 lg:row-start-1">
{profile.bio && (
<Section title={t('profile.bio.about_me', 'About Me')}>
<ProfileBio
@@ -490,7 +493,18 @@ function ProfileContent(props: {
/>
</Section>
)}
</div>
{/* 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. */}
<ProfilePhotoCarousel
urls={photos.visibleUrls.slice(1)}
descriptions={profile.image_descriptions as Record<string, string>}
lockedCount={photos.lockedCount}
indexOffset={1}
onSelect={photos.openAt}
/>
{photos.lightbox}
</Col>
{/* 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,

View File

@@ -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<string, string>
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<HTMLDivElement>(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 (
<Col className={clsx('gap-4', className)} data-testid="profile-photo-carousel">
<Row className="items-baseline justify-between">
<div
className="text-ink-400 font-dm-sans uppercase"
style={{fontSize: '10px', letterSpacing: '0.18em'}}
>
{t('profile.photos', 'Photos')}
</div>
{/*<div*/}
{/* aria-hidden*/}
{/* className={clsx(*/}
{/* 'text-ink-400 text-sm transition-opacity duration-200',*/}
{/* hasMore ? 'opacity-100' : 'opacity-0',*/}
{/* )}*/}
{/*>*/}
{/* {t('profile.photos.scroll_sideways', 'Scroll sideways')} →*/}
{/*</div>*/}
</Row>
{/* 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. */}
<div
ref={scrollRef}
className="scrollbar-visible -mx-1 flex snap-x gap-3 overflow-x-auto overflow-y-hidden overscroll-x-contain !overscroll-y-auto px-1 pb-3"
>
{urls.map((url, i) => (
<PhotoCard
key={url}
url={url}
caption={descriptions?.[url]}
onClick={onSelect && (() => onSelect(indexOffset + i))}
/>
))}
{lockedCount > 0 && (
<div
className={clsx(
'bg-canvas-100 border-canvas-300 text-ink-500 flex flex-none snap-start items-center justify-center rounded-[4px] border border-dashed',
CARD_HEIGHT_CLASS,
)}
style={{aspectRatio: MIN_ASPECT}}
>
<SignUpButton
text={`+${lockedCount}`}
size="xs"
color="none"
className="dark:text-ink-500 hover:text-primary-500"
/>
</div>
)}
</div>
</Col>
)
}
function PhotoCard(props: {url: string; caption?: string; onClick?: () => void}) {
const {url, caption, onClick} = props
const [aspect, setAspect] = useState<number | null>(null)
return (
<Col className="flex-none snap-start gap-2">
<div
className={clsx(
'border-canvas-300 relative overflow-hidden rounded-[4px] border',
CARD_HEIGHT_CLASS,
onClick && 'cursor-pointer',
)}
style={{aspectRatio: clamp(aspect ?? MIN_ASPECT, MIN_ASPECT, MAX_ASPECT)}}
>
<MediaTile
url={url}
sizes="(max-width: 768px) 60vw, 300px"
onAspect={setAspect}
onClick={onClick}
/>
</div>
{caption && (
<div className="text-primary-800 max-w-[34ch] text-sm leading-snug">{caption}</div>
)}
</Col>
)
}

View File

@@ -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. */
<MediaModal
urls={visibleUrls}
index={safeIndex}
setIndex={setIndex}
descriptions={profile.image_descriptions as Record<string, string>}
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 (
<video
src={url}
className={clsx('h-full w-full object-cover object-center', className)}
autoPlay
muted
loop
playsInline
onLoadedMetadata={(e) => {
const {videoWidth, videoHeight} = e.currentTarget
if (videoHeight) onAspect?.(videoWidth / videoHeight)
}}
onClick={onClick}
/>
)
}
return (
<Image
priority={priority}
src={url}
fill
sizes={sizes ?? '(max-width: 768px) 100vw, 40vw'}
alt=""
className={clsx('object-cover object-center', className)}
onLoad={(e) => {
const {naturalWidth, naturalHeight} = e.currentTarget
if (naturalHeight) onAspect?.(naturalWidth / naturalHeight)
}}
onClick={onClick}
/>
)
}

View File

@@ -1,35 +1,37 @@
import clsx from 'clsx'
import {convertGender, Gender} from 'common/gender'
import {getLocationText} from 'common/geodb'
import {Profile} from 'common/profiles/profile'
import {buildArray} from 'common/util/array'
import {capitalize} from 'lodash'
import React from 'react'
import {useMeasurementSystem} from 'web/hooks/use-measurement-system'
import {useT} from 'web/lib/locale'
import {formatProfileValue} from '../profile-about'
import {ProfileLocation} from './profile-location'
/**
* Place, age, height, gender as one editorial line separated by hairline slashes.
* Place, age, gender as one editorial line separated by hairline slashes.
*
* The icons this used to carry were doing no work — nobody needs a pictogram to parse "32" as an age
* once it sits in this sequence — and four of them turned the line under the name into visual clutter
* at exactly the point the eye should be moving fastest.
*/
export default function ProfilePrimaryInfo(props: {profile: Profile; short?: boolean}) {
const {profile, short = false} = props
export default function ProfilePrimaryInfo(props: {
profile: Profile
short?: boolean
/** Small, uppercase and tracked, for the line sitting above the name in the hero. */
eyebrow?: boolean
}) {
const {profile, short = false, eyebrow = false} = props
const t = useT()
const {measurementSystem} = useMeasurementSystem()
const parts = buildArray<React.ReactNode>(
// Checked here rather than left to ProfileLocation: a component that renders null still counts as
// an entry, which would leave the line starting on a stray separator.
!!getLocationText(profile) && <ProfileLocation profile={profile} hideIcon />,
profile.age && String(profile.age),
!short &&
profile.height_in_inches != null &&
formatProfileValue('height_in_inches', profile.height_in_inches, measurementSystem, t),
// Height is not here: it lives in the Details rail, where a number people filter on belongs.
// In this line it read as a headline fact and pushed the eye past the gender that follows it.
!short &&
profile.gender &&
capitalize(t(`profile.gender.${profile.gender}`, convertGender(profile.gender as Gender))),
@@ -39,14 +41,20 @@ export default function ProfilePrimaryInfo(props: {profile: Profile; short?: boo
return (
<div
className="text-ink-500 flex flex-wrap items-center"
className={clsx(
'text-ink-500 flex flex-wrap items-center',
eyebrow && 'font-dm-sans uppercase',
)}
data-testid="profile-gender-location-height-inches"
style={{fontSize: '15px'}}
style={eyebrow ? {fontSize: '11px', letterSpacing: '0.16em'} : {fontSize: '15px'}}
>
{parts.map((part, i) => (
<React.Fragment key={i}>
{i > 0 && (
<span aria-hidden className="text-ink-400 px-3.5 select-none">
<span
aria-hidden
className={clsx('text-ink-400 select-none', eyebrow ? 'px-2.5' : 'px-3.5')}
>
/
</span>
)}

View File

@@ -0,0 +1,37 @@
import {RefObject, useState} from 'react'
import {useSafeLayoutEffect} from './use-safe-layout-effect'
export type ElementSize = {width: number; height: number}
/**
* Live border-box size of an element, or null until it has been laid out.
*
* Layout effect so the first paint after mount already has the real numbers: callers use this to
* size a sibling, and a frame at the fallback size reads as a flicker.
*/
export function useElementSize(ref: RefObject<HTMLElement | null>) {
const [size, setSize] = useState<ElementSize | null>(null)
useSafeLayoutEffect(() => {
const el = ref.current
if (!el) return
const measure = () => {
const {width, height} = el.getBoundingClientRect()
if (!width && !height) return
setSize((prev) =>
prev && Math.abs(prev.width - width) < 0.5 && Math.abs(prev.height - height) < 0.5
? prev
: {width, height},
)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [ref])
return size
}

View File

@@ -45,7 +45,10 @@ function openDb(): Promise<IDBDatabase> {
})
}
function tx<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
function tx<T>(
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return openDb().then(
(db) =>
new Promise<T>((resolve, reject) => {