Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Photogallerymodal tags #976

Closed
wants to merge 11 commits into from
2 changes: 0 additions & 2 deletions .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,9 @@ jobs:

- name: Run unit tests
run: yarn test
if: ${{ github.event_name == 'pull_request' }}

- name: Build project
run: yarn build
if: ${{ github.event_name != 'pull_request' }}

test-docker-compose:
runs-on: ubuntu-latest
Expand Down
83 changes: 83 additions & 0 deletions src/components/media/GalleryImageCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useState } from 'react'
import Image from 'next/image'
import clx from 'classnames'
import Card from '../ui/Card/Card'
import TagList from '../media/TagList'
import { MobileLoader } from '../../js/sirv/util'
import { MediaWithTags } from '../../js/types'
import { getUploadDateSummary } from '../../js/utils'
import { PostHeader } from '../home/Post'

const MOBILE_IMAGE_MAX_WIDITH = 600

interface GalleryImageCardProps {
header?: JSX.Element
mediaWithTags: MediaWithTags
onImageClick?: (event: React.MouseEvent<HTMLImageElement>, mediaWithTags: MediaWithTags) => void
}

/**
* Image card for the gallery page
*/
export const GalleryImageCard = ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Visually these cards are very similar to RecentMedia cards. Let's merge after you rebase and refactor later.

mediaWithTags,
onImageClick
}: GalleryImageCardProps): JSX.Element => {
const [loaded, setLoaded] = useState(false)
const { mediaUrl, width, height, username } = mediaWithTags
const imageRatio = width / height
return (
<Card
header={<PostHeader username={username} />}
image={
<div className='relative block w-full h-full'>
<Image
src={MobileLoader({
src: mediaUrl,
width: MOBILE_IMAGE_MAX_WIDITH
})}
width={MOBILE_IMAGE_MAX_WIDITH}
height={MOBILE_IMAGE_MAX_WIDITH / imageRatio}
sizes='100vw'
objectFit='cover'
onLoad={() => setLoaded(true)}
className='rounded-md'
onClick={(event) => {
if (onImageClick != null) {
console.log('onImageClick')
event.stopPropagation()
event.preventDefault()
onImageClick(event, mediaWithTags)
}
}}
/>
<div
className={clx(
'absolute top-0 left-0 w-full h-full',
loaded
? 'bg-transparent'
: 'bg-gray-50 bg-opacity-60 border animate-pulse'
)}
>
{loaded}
</div>
</div>
}
body={
<>
<section className='flex flex-col gap-y-4 justify-between px-2'>
<TagList
mediaWithTags={mediaWithTags}
showActions={false}
isAuthorized={false}
isAuthenticated={false}
/>
<span className='uppercase text-xs text-base-200'>
{getUploadDateSummary(mediaWithTags.uploadTime)}
</span>
</section>
</>
}
/>
)
}
39 changes: 21 additions & 18 deletions src/components/media/PhotoGalleryModal.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
import React, { Dispatch, SetStateAction, useState } from 'react'
import { userMediaStore } from '../../js/stores/media'
import ResponsiveImage, { ResponsiveImage2 } from './slideshow/ResponsiveImage'
import ResponsiveImage from './slideshow/ResponsiveImage'
import { MobileDialog, DialogContent } from '../ui/MobileDialog'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { MediaWithTags } from '../../js/types'
import { GalleryImageCard } from './GalleryImageCard'

interface PhotoGalleryModalProps {
export interface PhotoGalleryModalProps {
setShowPhotoGalleryModal: Dispatch<SetStateAction<boolean>>
}

/*
* A reusable popup alert
* A modal component that displays a photo gallery.
* @param setShowPhotoGallery A setState action that toggles the photo gallery modal on click.
*/
const PhotoGalleryModal = ({
setShowPhotoGalleryModal
}: PhotoGalleryModalProps): JSX.Element => {
const [ShowImage, setShowImage] = useState('')
// State to keep track of the image details being viewed in full screen.
const [imageProperties, setImageProperties] = useState<MediaWithTags | null>(null)

// Fetch the list of photos.
const photoList = userMediaStore.use.photoList()

return (
<MobileDialog
modal
Expand All @@ -25,40 +31,37 @@ const PhotoGalleryModal = ({
>
<DialogContent fullScreen title='Gallery'>
<div className='px-0 lg:px-4 mt-20 relative columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-2 lg:gap-4'>
{photoList.map((element) => {
const { mediaUrl, width, height } = element
{photoList.map((mediaWithTags) => {
return (
<div
onClick={() => setShowImage(element.mediaUrl)}
key={element.mediaUrl}
className='overflow-hidden mt-0 mb-2 lg:mb-4 hover:brightness-75 break-inside-avoid-column cursor-pointer break-inside-avoid relative block rounded-md'
data-testid='thumbnail'
onClick={() => setImageProperties(mediaWithTags)}
key={mediaWithTags.mediaUrl}
className='overflow-hidden hover:brightness-75 break-inside-avoid-column cursor-pointer relative block rounded-md mb-4'
>
<ResponsiveImage2
naturalWidth={width}
naturalHeight={height}
mediaUrl={mediaUrl}
isHero={false}
/>
<GalleryImageCard key={mediaWithTags.mediaUrl} mediaWithTags={mediaWithTags} onImageClick={() => setImageProperties(mediaWithTags)} />
</div>
)
})}
</div>

{/* Full-screen view of selected image */}
{
ShowImage !== ''
(imageProperties != null) && imageProperties.mediaUrl !== ''
? (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75'>
<div className='relative w-full h-full'>
<div className='absolute top-2 right-2 z-50'>
<button
onClick={() => setShowImage('')}
data-testid='close-fullscreen-button'
onClick={() => setImageProperties(null)}
className='bg-white hover:bg-gray-500 bg-opacity-50 rounded-full duration-300 ease-in-out p-2'
>
<XMarkIcon className='w-6 h-6' />
</button>
</div>
<div className='flex items-center justify-center h-full'>
<ResponsiveImage mediaUrl={ShowImage} isHero={false} />
<ResponsiveImage mediaUrl={imageProperties.mediaUrl} isHero={false} />
</div>
</div>
</div>
Expand Down
106 changes: 76 additions & 30 deletions src/components/media/Tag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,42 @@ import { EntityTag, TagTargetType } from '../../js/types'
import { OnDeleteCallback } from './TagList'
import { MouseEventHandler } from 'react'

interface PhotoTagProps {
const stopPropagation: MouseEventHandler = (event) => event.stopPropagation()

const baseTag = 'badge badge-outline hover:underline max-w-full'
const lgTag = 'badge-lg gap-2'
const mdTag = 'gap-1'
const sizeMap = { md: mdTag, lg: lgTag }

interface BaseTagProps {
size?: 'md' | 'lg'
url?: string
className?: string
children: React.ReactNode
}

/**
* Base tag component that can be used to create other tag components.
* Clicking on it leads to the url provided.
* @param {BaseTagProps} props - Properties for the BaseTag component
*/
export const BaseTag: React.FC<BaseTagProps> = ({ size = 'md', url, className, children }) => {
return (
<div className='w-full' data-testid='base-tag'>
<Link href={url ?? '#'} prefetch={false}>
<a
data-testid='base-tag-link'
onClick={stopPropagation}
className={clx(baseTag, sizeMap[size], className)}
>
{children}
</a>
</Link>
</div>
)
}

interface LocationTagProps {
mediaId: string
tag: EntityTag
onDelete: OnDeleteCallback
Expand All @@ -16,45 +51,56 @@ interface PhotoTagProps {
size?: 'md' | 'lg'
}

export default function Tag ({ mediaId, tag, onDelete, size = 'md', showDelete = false, isAuthorized = false }: PhotoTagProps): JSX.Element | null {
/**
* Tag associated with specific locations, such as climbs or areas.
* If isAuthorized is true, a delete button will be displayed.
* @param {LocationTagProps} props - Properties for the LocationTag component
*/
export const LocationTag: React.FC<LocationTagProps> = ({ mediaId, tag, onDelete, size = 'md', showDelete = false, isAuthorized = false }) => {
const [url, name] = resolver(tag)
if (url == null || name == null) return null
const isArea = tag.type === TagTargetType.area

return (
<Link href={url} prefetch={false}>
<a
className={
clx('badge hover:underline max-w-full',
isArea ? 'badge-info bg-opacity-60' : 'badge-outline',
size === 'lg' ? 'badge-lg gap-2' : 'gap-1')
}
onClick={stopPropagation}
title={name}
>
{isArea && <div className='h-6 w-6 grid place-content-center'><NetworkSquareIcon className='w-6 h-6' /></div>}
<BaseTag url={url} size={size} className={isArea ? 'badge-info bg-opacity-60' : 'badge-outline'}>
{isArea && <div className='h-6 w-6 grid place-content-center'><NetworkSquareIcon className='w-6 h-6' /></div>}
<div className='mt-0.5 whitespace-nowrap truncate text-sm'>{name}</div>
{isAuthorized && showDelete &&
<button
onClick={(e) => {
e.preventDefault()
void onDelete({ mediaId, tagId: tag.id, entityId: tag.targetId, entityType: tag.type })
}}
title='Delete tag'
>
<div className='rounded-full -mr-2.5'>
<XCircleIcon className='cursor-pointer stroke-1 hover:stroke-2 w-5 h-5' />
</div>
</button>}
</BaseTag>
)
}

interface UsernameTagProps {
username: string
size?: 'md' | 'lg'
}

<div className='mt-0.5 whitespace-nowrap truncate text-sm'>{name}</div>
{isAuthorized && showDelete &&
<button
onClick={(e) => {
e.preventDefault()
void onDelete({ mediaId, tagId: tag.id, entityId: tag.targetId, entityType: tag.type })
}}
title='Delete tag'
>
<div className='rounded-full -mr-2.5'>
<XCircleIcon className={clx('cursor-pointer stroke-1 hover:stroke-2', size === 'lg' ? 'w-6 h-6' : 'w-5 h-5')} />
</div>
</button>}
</a>
</Link>
/**
* Tag that display a username.
* Clicking on it leads to the user's profile page.
* @param {UsernameTagProps} props - Properties for the UsernameTag component
*/
export const UsernameTag: React.FC<UsernameTagProps> = ({ username, size = 'md' }) => {
if (username === undefined || username.trim() === '') return null

return (
<BaseTag url={`/u/${username}`} size={size} className='bg-black border border-gray-900 text-white bg-opacity-70'>
{username}
</BaseTag>
)
}

const stopPropagation: MouseEventHandler = (event) => event.stopPropagation()

/**
* Extract entity url and name from a tag
* @param tag
Expand Down
17 changes: 11 additions & 6 deletions src/components/media/TagList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { signIn, useSession } from 'next-auth/react'
import AddTag from './AddTag'
import { DropdownMenu, DropdownContent, DropdownTrigger, DropdownItem, DropdownSeparator } from '../ui/DropdownMenu'
import { EntityTag, MediaWithTags } from '../../js/types'
import Tag from './Tag'
import useMediaCmd, { RemoveEntityTagProps } from '../../js/hooks/useMediaCmd'
import { AddEntityTagProps } from '../../js/graphql/gql/tags'
import { LocationTag, UsernameTag } from './Tag'

export type OnAddCallback = (args: AddEntityTagProps) => Promise<void>

Expand All @@ -22,12 +22,13 @@ interface TagsProps {
showDelete?: boolean
showActions?: boolean
className?: string
showUsernameTag?: boolean
}

/**
* A horizontal tag list. The last item is a CTA.
*/
export default function TagList ({ mediaWithTags, isAuthorized = false, isAuthenticated = false, showDelete = false, showActions = true, className = '' }: TagsProps): JSX.Element | null {
export default function TagList ({ mediaWithTags, isAuthorized = false, isAuthenticated = false, showDelete = false, showActions = true, className = '', showUsernameTag = false }: TagsProps): JSX.Element | null {
const { addEntityTagCmd, removeEntityTagCmd } = useMediaCmd()
const session = useSession()

Expand All @@ -43,7 +44,7 @@ export default function TagList ({ mediaWithTags, isAuthorized = false, isAuthen
await removeEntityTagCmd(args, session.data?.accessToken)
}

const { entityTags, id } = mediaWithTags
const { entityTags, id, username } = mediaWithTags

return (
<div className={
Expand All @@ -53,8 +54,11 @@ export default function TagList ({ mediaWithTags, isAuthorized = false, isAuthen
)
}
>
{showUsernameTag &&
username !== undefined &&
<UsernameTag username={username} />}
{entityTags.map((tag: EntityTag) =>
<Tag
<LocationTag
key={`${tag.targetId}`}
mediaId={id}
tag={tag}
Expand Down Expand Up @@ -95,7 +99,7 @@ export const MobilePopupTagList: React.FC<TagListProps> = ({ mediaWithTags, isAu
const onDeleteHandler: OnDeleteCallback = async (args) => {
await removeEntityTagCmd(args, session.data?.accessToken)
}
const { id, entityTags } = mediaWithTags
const { id, entityTags, username } = mediaWithTags
return (
<div aria-label='tag popup'>
<DropdownMenu>
Expand All @@ -104,9 +108,10 @@ export const MobilePopupTagList: React.FC<TagListProps> = ({ mediaWithTags, isAu
</DropdownTrigger>
<DropdownContent align='end'>
<>
{username !== undefined && <UsernameTag username={username} size='lg' />}
{entityTags.map(tag => (
<PrimitiveDropdownMenuItem key={`${tag.id}`} className='px-2 py-3'>
<Tag
<LocationTag
mediaId={id}
tag={tag}
isAuthorized={isAuthorized}
Expand Down
Loading
Loading