mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-26 02:21:06 -04:00
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
import {JSONContent} from '@tiptap/core'
|
|
import clsx from 'clsx'
|
|
import {INVERTED_DIET_CHOICES, INVERTED_LANGUAGE_CHOICES} from 'common/choices'
|
|
import {Gender} from 'common/gender'
|
|
import {CompatibilityScore} from 'common/profiles/compatibility-score'
|
|
import {Profile} from 'common/profiles/profile'
|
|
import {DisplayOptions} from 'common/profiles-rendering'
|
|
import {capitalize} from 'lodash'
|
|
import {
|
|
Brain,
|
|
Briefcase,
|
|
Calendar,
|
|
Cigarette,
|
|
HandHeart,
|
|
Languages,
|
|
Salad,
|
|
Sparkles,
|
|
Wine,
|
|
} from 'lucide-react'
|
|
import Image from 'next/image'
|
|
import Link from 'next/link'
|
|
import React, {useEffect, useRef, useState} from 'react'
|
|
import {PiMagnifyingGlassBold} from 'react-icons/pi'
|
|
import GenderIcon from 'web/components/gender-icon'
|
|
import {IconWithInfo} from 'web/components/icons'
|
|
import {Row} from 'web/components/layout/row'
|
|
import {ProfileLocation} from 'web/components/profile/profile-location'
|
|
import {getSeekingText} from 'web/components/profile-about'
|
|
import {CompatibleBadge} from 'web/components/widgets/compatible-badge'
|
|
import {Content} from 'web/components/widgets/editor'
|
|
import HideProfileButton from 'web/components/widgets/hide-profile-button'
|
|
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
|
|
import {LoadMoreUntilNotVisible} from 'web/components/widgets/visibility-observer'
|
|
import {useChoicesContext} from 'web/hooks/use-choices'
|
|
import {useUser} from 'web/hooks/use-user'
|
|
import {useT} from 'web/lib/locale'
|
|
import {getSeekingConnectionText} from 'web/lib/profile/seeking'
|
|
|
|
import {Col} from './layout/col'
|
|
|
|
export const ProfileGrid = (props: {
|
|
profiles: Profile[]
|
|
loadMore: () => Promise<boolean>
|
|
isLoadingMore: boolean
|
|
isReloading: boolean
|
|
compatibilityScores: Record<string, CompatibilityScore> | undefined
|
|
starredUserIds: string[] | undefined
|
|
refreshStars: () => Promise<void>
|
|
onHide?: (userId: string) => void
|
|
hiddenUserIds?: string[]
|
|
onUndoHidden?: (userId: string) => void
|
|
displayOptions?: Partial<DisplayOptions>
|
|
}) => {
|
|
const {
|
|
profiles,
|
|
loadMore,
|
|
isLoadingMore,
|
|
isReloading,
|
|
compatibilityScores,
|
|
starredUserIds,
|
|
refreshStars,
|
|
onHide,
|
|
hiddenUserIds,
|
|
onUndoHidden,
|
|
displayOptions,
|
|
} = props
|
|
|
|
const {cardSize} = displayOptions ?? {}
|
|
|
|
const user = useUser()
|
|
const t = useT()
|
|
|
|
const other_profiles = profiles.filter((profile) => profile.user_id !== user?.id)
|
|
|
|
const gridCols = {
|
|
small: 'lg:grid-cols-2',
|
|
medium: '',
|
|
large: '',
|
|
}[cardSize ?? 'medium']
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div
|
|
className={clsx(
|
|
`grid gap-6 py-4 grid-cols-1`,
|
|
isReloading && 'animate-pulse opacity-80',
|
|
gridCols,
|
|
)}
|
|
>
|
|
{other_profiles.map((profile) => (
|
|
<ProfilePreview
|
|
key={profile.id}
|
|
profile={profile}
|
|
compatibilityScore={compatibilityScores?.[profile.user_id]}
|
|
hasStar={starredUserIds?.includes(profile.user_id) ?? false}
|
|
refreshStars={refreshStars}
|
|
onHide={onHide}
|
|
isHidden={hiddenUserIds?.includes(profile.user_id) ?? false}
|
|
onUndoHidden={onUndoHidden}
|
|
displayOptions={displayOptions}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<LoadMoreUntilNotVisible loadMore={loadMore} />
|
|
|
|
{isLoadingMore && (
|
|
<div className="flex justify-center py-4">
|
|
<CompassLoadingIndicator />
|
|
</div>
|
|
)}
|
|
|
|
{!isLoadingMore && !isReloading && other_profiles.length === 0 && (
|
|
<div className="py-8 text-center">
|
|
<p>{t('profile_grid.no_profiles', 'No profiles found.')}</p>
|
|
<p>
|
|
{t(
|
|
'profile_grid.notification_cta',
|
|
"Feel free to click on Get Notified and we'll notify you when new users match your search!",
|
|
)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ProfilePreview(props: {
|
|
profile: Profile
|
|
compatibilityScore: CompatibilityScore | undefined
|
|
hasStar: boolean
|
|
refreshStars: () => Promise<void>
|
|
onHide?: (userId: string) => void
|
|
isHidden?: boolean
|
|
onUndoHidden?: (userId: string) => void
|
|
displayOptions?: Partial<DisplayOptions>
|
|
}) {
|
|
const {profile, compatibilityScore, onHide, isHidden, onUndoHidden, displayOptions} = props
|
|
|
|
const {
|
|
showPhotos,
|
|
showAge,
|
|
showGender,
|
|
showLanguages,
|
|
showHeadline,
|
|
showKeywords,
|
|
showCity,
|
|
showOccupation,
|
|
showSeeking,
|
|
showInterests,
|
|
showCauses,
|
|
showDiet,
|
|
showSmoking,
|
|
showDrinks,
|
|
showMBTI,
|
|
showBio,
|
|
cardSize,
|
|
} = displayOptions ?? {}
|
|
const {user} = profile
|
|
const choicesIdsToLabels = useChoicesContext()
|
|
const t = useT()
|
|
// const currentUser = useUser()
|
|
|
|
// Show the bottom transparent gradient only if the text can't fit the card
|
|
const textRef = useRef<HTMLDivElement>(null)
|
|
const [isOverflowing, setIsOverflowing] = useState(false)
|
|
useEffect(() => {
|
|
const el = textRef.current
|
|
if (!el) return
|
|
const check = () => setIsOverflowing(el.scrollHeight > el.clientHeight)
|
|
check()
|
|
const ro = new ResizeObserver(check)
|
|
ro.observe(el)
|
|
return () => ro.disconnect()
|
|
}, [])
|
|
|
|
const bio = profile.bio as JSONContent
|
|
|
|
// If this profile was just hidden, render a compact placeholder with Undo action.
|
|
if (isHidden) {
|
|
return (
|
|
<div className="block rounded-lg border border-canvas-300 bg-canvas-50 dark:bg-gray-800/50 p-3 text-sm">
|
|
<Row className="items-center justify-between gap-2">
|
|
<span className="text-ink-700 dark:text-ink-300">
|
|
{t(
|
|
'profile_grid.profile_hidden_short',
|
|
"You won't see {name} in your search results anymore.",
|
|
{name: user?.name},
|
|
)}
|
|
</span>
|
|
<button
|
|
className="text-primary-500 hover:text-primary-700 underline"
|
|
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
onUndoHidden?.(profile.user_id)
|
|
}}
|
|
>
|
|
{t('profile_grid.undo', 'Undo')}
|
|
</button>
|
|
</Row>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (bio && bio.content) {
|
|
const newBio = []
|
|
let i = 0
|
|
for (const c of bio.content) {
|
|
if ((c?.content?.length || 0) == 0) continue
|
|
if (c.type === 'paragraph') {
|
|
newBio.push(c)
|
|
} else if (['heading'].includes(c.type ?? '')) {
|
|
newBio.push({
|
|
type: 'paragraph',
|
|
content: c.content,
|
|
})
|
|
} else if (c.type === 'image') {
|
|
continue
|
|
} else {
|
|
newBio.push(c)
|
|
}
|
|
i += 1
|
|
if (i >= 5) break
|
|
}
|
|
bio.content = newBio
|
|
}
|
|
|
|
const seekingText = profile.pref_relation_styles?.length
|
|
? cardSize === 'large'
|
|
? getSeekingText(profile, t, true)
|
|
: getSeekingConnectionText(profile, t, true)
|
|
: null
|
|
|
|
// if (!profile.work?.length && !profile.occupation_title && !profile.interests?.length && (profile.bio_length || 0) < 100) {
|
|
// return null
|
|
// }
|
|
|
|
const isPhotoRendered = showPhotos !== false && profile.pinned_url
|
|
|
|
const textHeightClass = {
|
|
small: 'max-h-40',
|
|
medium: 'max-h-60 lg:max-h-40',
|
|
large: 'max-h-80',
|
|
}[cardSize ?? 'medium']
|
|
|
|
const photoSizeClass = {
|
|
small: 'w-24 h-auto min-h-24 mt-12',
|
|
medium: 'w-20 lg:w-28 h-24 self-end lg:h-auto min-h-20 mt-12',
|
|
large: 'w-48 lg:w-48 h-60 lg:h-auto min-h-48 lg:mt-12',
|
|
}[cardSize ?? 'medium']
|
|
|
|
const cardClass = {
|
|
small: 'flex-row',
|
|
medium: 'flex-row',
|
|
large: 'flex-col',
|
|
}[cardSize ?? 'medium']
|
|
|
|
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
|
|
|
|
return (
|
|
<Link
|
|
// onClick={() => track('click profile preview')}
|
|
href={`/${user.username}`}
|
|
className={clsx(
|
|
'cursor-pointer group block rounded-lg overflow-hidden bg-transparent h-full border border-canvas-300',
|
|
hover,
|
|
)}
|
|
>
|
|
<Col className={clsx('relative w-full rounded transition-all')}>
|
|
<Row className={clsx('absolute top-2 right-2 items-start justify-end px-2 pb-3 z-10')}>
|
|
{compatibilityScore && (
|
|
<CompatibleBadge compatibility={compatibilityScore} className={'pt-1'} />
|
|
)}
|
|
{onHide && (
|
|
<HideProfileButton
|
|
hiddenUserId={profile.user_id}
|
|
onHidden={onHide}
|
|
className="ml-2"
|
|
stopPropagation
|
|
eyeOff
|
|
/>
|
|
)}
|
|
</Row>
|
|
|
|
<div className={clsx('flex lg:flex-row h-full lg:justify-between', cardClass)}>
|
|
<div
|
|
ref={textRef}
|
|
className={clsx(
|
|
'relative min-w-0 px-4 py-2 overflow-hidden lg:flex-1',
|
|
textHeightClass,
|
|
)}
|
|
>
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white truncate my-0">
|
|
{user.name}
|
|
</h3>
|
|
<Row className={'flex-wrap gap-x-2'}>
|
|
{showCity !== false && <ProfileLocation profile={profile} />}
|
|
{showAge !== false && profile.age && (
|
|
<IconWithInfo
|
|
text={t('profile.header.age', '{age} years old', {age: profile.age})}
|
|
icon={<Calendar className="h-4 w-4 " />}
|
|
/>
|
|
)}
|
|
{showGender !== false && profile.gender && (
|
|
<IconWithInfo
|
|
text={''}
|
|
icon={<GenderIcon gender={profile.gender as Gender} className="h-4 w-4 " />}
|
|
/>
|
|
)}
|
|
</Row>
|
|
{showHeadline !== false && profile.headline && (
|
|
<p className="italic my-0">"{profile.headline}"</p>
|
|
)}
|
|
{showKeywords !== false && !!profile.keywords?.length && (
|
|
<Row className={'gap-2 flex-wrap py-2'} data-testid="profile-keywords">
|
|
{profile.keywords
|
|
?.slice(0, 10)
|
|
?.map(capitalize)
|
|
?.map((tag, i) => (
|
|
<span key={i} className={'bg-primary-100/50 text-sm px-3 py-2 rounded-full'}>
|
|
{tag.trim()}
|
|
</span>
|
|
))}
|
|
</Row>
|
|
)}
|
|
{showSeeking !== false && seekingText && (
|
|
<IconWithInfo
|
|
text={seekingText}
|
|
icon={<PiMagnifyingGlassBold className="h-4 w-4 " />}
|
|
/>
|
|
)}
|
|
{showOccupation !== false && profile.occupation_title && (
|
|
<IconWithInfo
|
|
text={profile.occupation_title}
|
|
icon={<Briefcase className="h-4 w-4 " />}
|
|
/>
|
|
)}
|
|
{showInterests !== false && !!profile.interests?.length && (
|
|
<IconWithInfo
|
|
text={profile.interests
|
|
?.slice(0, 5)
|
|
.map((id) => choicesIdsToLabels['interests'][id])
|
|
.join(' • ')}
|
|
icon={<Sparkles className="h-4 w-4 " />}
|
|
/>
|
|
)}
|
|
{showCauses !== false && !!profile.causes?.length && (
|
|
<IconWithInfo
|
|
text={profile.causes
|
|
?.slice(0, 5)
|
|
.map((id) => choicesIdsToLabels['causes'][id])
|
|
.join(' • ')}
|
|
icon={<HandHeart className="h-4 w-4 " />}
|
|
/>
|
|
)}
|
|
<Row className={'gap-2 flex-wrap'}>
|
|
{showDiet !== false && !!profile.diet?.length && (
|
|
<IconWithInfo
|
|
text={profile.diet
|
|
?.map((e) => t(`profile.diet.${e}`, INVERTED_DIET_CHOICES[e]))
|
|
.join(' • ')}
|
|
icon={<Salad className="h-4 w-4 " />}
|
|
/>
|
|
)}
|
|
{showSmoking !== false && profile.is_smoker && (
|
|
<IconWithInfo
|
|
text={t('profile.optional.smoking', 'Smokes')}
|
|
icon={<Cigarette className="h-4 w-4 " />}
|
|
/>
|
|
)}
|
|
{showDrinks !== false &&
|
|
profile.drinks_per_month !== null &&
|
|
profile.drinks_per_month !== undefined && (
|
|
<IconWithInfo
|
|
text={`${profile.drinks_per_month} ${t('filter.drinks.per_month', 'per month')}`}
|
|
icon={<Wine className="h-4 w-4 " />}
|
|
/>
|
|
)}
|
|
{showMBTI !== false && profile.mbti && (
|
|
<IconWithInfo
|
|
text={profile.mbti.toUpperCase()}
|
|
icon={<Brain className="h-4 w-4 " />}
|
|
/>
|
|
)}
|
|
{showLanguages !== false && !!profile.languages?.length && (
|
|
<IconWithInfo
|
|
text={profile.languages
|
|
?.map((v) => t(`profile.language.${v}`, INVERTED_LANGUAGE_CHOICES[v]))
|
|
.join(' • ')}
|
|
icon={<Languages className="h-4 w-4 " />}
|
|
/>
|
|
)}
|
|
</Row>
|
|
{showBio !== false && bio && (
|
|
<div className="border-l-2 border-gray-200 dark:border-gray-600 pl-3 mt-1">
|
|
<Content className="w-full italic" content={bio} />
|
|
</div>
|
|
)}
|
|
{isOverflowing && (
|
|
<div
|
|
className={clsx(
|
|
'absolute bottom-0 inset-x-0 h-16 bg-gradient-to-t from-canvas-0 to-transparent pointer-events-none',
|
|
'group-hover:from-gray-50 dark:group-hover:from-canvas-100',
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
{isPhotoRendered && (
|
|
<div
|
|
className={clsx(
|
|
'relative shrink-0 rounded-xl lg:self-stretch overflow-hidden z-1 mx-auto',
|
|
photoSizeClass,
|
|
)}
|
|
>
|
|
<Image
|
|
src={profile.pinned_url!}
|
|
fill
|
|
alt=""
|
|
className="object-cover object-top"
|
|
loading="lazy"
|
|
priority={false}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Col>
|
|
</Link>
|
|
)
|
|
}
|