feat: add lightbox to profile avatar and banner (#661)

This commit is contained in:
Alex Gleason 2025-11-14 08:02:21 -06:00 committed by GitHub
parent 82c13006ff
commit 19eaf1a4e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 160 additions and 6 deletions

View file

@ -3,12 +3,13 @@ import FollowButton from '@/components/FollowButton'
import Nip05 from '@/components/Nip05' import Nip05 from '@/components/Nip05'
import NpubQrCode from '@/components/NpubQrCode' import NpubQrCode from '@/components/NpubQrCode'
import ProfileAbout from '@/components/ProfileAbout' import ProfileAbout from '@/components/ProfileAbout'
import ProfileBanner from '@/components/ProfileBanner' import { BannerWithLightbox } from '@/components/ProfileBanner'
import ProfileOptions from '@/components/ProfileOptions' import ProfileOptions from '@/components/ProfileOptions'
import ProfileZapButton from '@/components/ProfileZapButton' import ProfileZapButton from '@/components/ProfileZapButton'
import PubkeyCopy from '@/components/PubkeyCopy' import PubkeyCopy from '@/components/PubkeyCopy'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { AvatarWithLightbox } from '@/components/UserAvatar'
import { useFetchFollowings, useFetchProfile } from '@/hooks' import { useFetchFollowings, useFetchProfile } from '@/hooks'
import { toMuteList, toProfileEditor } from '@/lib/link' import { toMuteList, toProfileEditor } from '@/lib/link'
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager' import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
@ -20,7 +21,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NotFound from '../NotFound' import NotFound from '../NotFound'
import SearchInput from '../SearchInput' import SearchInput from '../SearchInput'
import { SimpleUserAvatar } from '../UserAvatar'
import FollowedBy from './FollowedBy' import FollowedBy from './FollowedBy'
import Followings from './Followings' import Followings from './Followings'
import ProfileFeed from './ProfileFeed' import ProfileFeed from './ProfileFeed'
@ -114,10 +114,11 @@ export default function Profile({ id }: { id?: string }) {
<> <>
<div ref={topContainerRef}> <div ref={topContainerRef}>
<div className="relative bg-cover bg-center mb-2"> <div className="relative bg-cover bg-center mb-2">
<ProfileBanner banner={banner} pubkey={pubkey} className="w-full aspect-[3/1]" /> <BannerWithLightbox banner={banner} pubkey={pubkey} className="w-full aspect-[3/1]" />
<SimpleUserAvatar <AvatarWithLightbox
userId={pubkey} userId={pubkey}
className="w-24 h-24 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background rounded-full" size="large"
className="absolute left-3 bottom-0 translate-y-1/2 border-4 border-background"
/> />
</div> </div>
<div className="px-4"> <div className="px-4">

View file

@ -1,6 +1,11 @@
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import modalManager from '@/services/modal-manager.service'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image' import Image from '../Image'
export default function ProfileBanner({ export default function ProfileBanner({
@ -32,3 +37,78 @@ export default function ProfileBanner({
/> />
) )
} }
export function BannerWithLightbox({
pubkey,
banner,
className
}: {
pubkey: string
banner?: string
className?: string
}) {
const id = useMemo(() => `profile-banner-lightbox-${randomString()}`, [])
const defaultBanner = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
const [bannerUrl, setBannerUrl] = useState(banner ?? defaultBanner)
const [index, setIndex] = useState(-1)
useEffect(() => {
if (banner) {
setBannerUrl(banner)
} else {
setBannerUrl(defaultBanner)
}
}, [defaultBanner, banner])
useEffect(() => {
if (index >= 0) {
modalManager.register(id, () => {
setIndex(-1)
})
} else {
modalManager.unregister(id)
}
}, [index, id])
const handleBannerClick = (event: React.MouseEvent) => {
event.stopPropagation()
event.preventDefault()
setIndex(0)
}
return (
<>
<Image
image={{ url: bannerUrl, pubkey }}
alt={`${pubkey} banner`}
className={cn('rounded-none', className)}
classNames={{
wrapper: 'cursor-zoom-in'
}}
errorPlaceholder={defaultBanner}
onClick={handleBannerClick}
/>
{index >= 0 &&
createPortal(
<div onClick={(e) => e.stopPropagation()}>
<Lightbox
index={index}
slides={[{ src: bannerUrl }]}
plugins={[Zoom]}
open={index >= 0}
close={() => setIndex(-1)}
controller={{
closeOnBackdropClick: true,
closeOnPullUp: true,
closeOnPullDown: true
}}
styles={{
toolbar: { paddingTop: '2.25rem' }
}}
/>
</div>,
document.body
)}
</>
)
}

View file

@ -3,9 +3,14 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { SecondaryPageLink } from '@/PageManager' import { SecondaryPageLink } from '@/PageManager'
import { useMemo } from 'react' import modalManager from '@/services/modal-manager.service'
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image' import Image from '../Image'
import ProfileCard from '../ProfileCard' import ProfileCard from '../ProfileCard'
@ -79,3 +84,71 @@ export function SimpleUserAvatar({
/> />
) )
} }
export function AvatarWithLightbox({
userId,
size = 'normal',
className
}: {
userId: string
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
className?: string
}) {
const id = useMemo(() => `user-avatar-lightbox-${randomString()}`, [])
const { profile } = useFetchProfile(userId)
const defaultAvatar = useMemo(
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
[profile]
)
const [index, setIndex] = useState(-1)
useEffect(() => {
if (index >= 0) {
modalManager.register(id, () => {
setIndex(-1)
})
} else {
modalManager.unregister(id)
}
}, [index, id])
const handleClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.stopPropagation()
e.preventDefault()
setIndex(0)
}
const imageUrl = profile?.avatar ?? defaultAvatar
return (
<>
<SimpleUserAvatar
userId={userId}
size={size}
className={cn('cursor-zoom-in', className)}
onClick={handleClick}
/>
{index >= 0 &&
createPortal(
<div onClick={(e) => e.stopPropagation()}>
<Lightbox
index={index}
slides={[{ src: imageUrl }]}
plugins={[Zoom]}
open={index >= 0}
close={() => setIndex(-1)}
controller={{
closeOnBackdropClick: true,
closeOnPullUp: true,
closeOnPullDown: true
}}
styles={{
toolbar: { paddingTop: '2.25rem' }
}}
/>
</div>,
document.body
)}
</>
)
}