mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
57
web/components/profile/profile-hero-photo.tsx
Normal file
57
web/components/profile/profile-hero-photo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
149
web/components/profile/profile-photo-carousel.tsx
Normal file
149
web/components/profile/profile-photo-carousel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
web/components/profile/profile-photos.tsx
Normal file
105
web/components/profile/profile-photos.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
37
web/hooks/use-element-size.ts
Normal file
37
web/hooks/use-element-size.ts
Normal 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
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user