Skip to content

Commit

Permalink
refactor: restore tagging feature (#869)
Browse files Browse the repository at this point in the history
* refactor: restore tagging feature
* wire api with infinity scroll
* refactor: consolidate media pagination/tagging in one hook
* refactor: get rid of zustood store for tags in favor of Apollo client cache
* refactor: maintain pagination data in gallery component
* feat: make the list of profile pre-builds customizable
  • Loading branch information
vnugent authored Jul 2, 2023
1 parent 849f33a commit a01e95b
Show file tree
Hide file tree
Showing 29 changed files with 637 additions and 446 deletions.
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ NEXT_PUBLIC_MAPBOX_API_KEY=pk.eyJ1IjoibWFwcGFuZGFzIiwiYSI6ImNsZG1wcnBhZTA5eXozb3

# Open Collective API URL
OPEN_COLLECTIVE_API_URI=https://api.opencollective.com/graphql/v2

# A comma-separate-list of profiles to pre-build
PREBUILD_PROFILES=
6 changes: 6 additions & 0 deletions __mocks__/next-auth/react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function useSession () {
return {
status: 'authenticated',
data: {}
}
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"dependencies": {
"@algolia/autocomplete-js": "1.7.1",
"@algolia/autocomplete-theme-classic": "1.7.1",
"@apollo/client": "^3.6.9",
"@apollo/client": "^3.7.16",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
Expand All @@ -27,7 +27,7 @@
"@turf/bbox": "^6.5.0",
"@types/underscore": "^1.11.4",
"@types/uuid": "^8.3.4",
"@udecode/zustood": "^0.4.4",
"@udecode/zustood": "^1.1.3",
"auth0": "^2.42.0",
"awesome-debounce-promise": "^2.1.0",
"aws-sdk": "^2.1265.0",
Expand Down
15 changes: 6 additions & 9 deletions src/components/UploadPhotoTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import clx from 'classnames'
import usePhotoUploader from '../js/hooks/usePhotoUploader'
import { userMediaStore, revalidateUserHomePage } from '../js/stores/media'
import useReturnToProfile from '../js/hooks/useReturnToProfile'
import usePhotoTag from '../js/hooks/usePhotoTagCmd'
import { mediaUrlHash } from '../js/sirv/SirvClient'
import { BlockingAlert } from './ui/micro/AlertDialogue'

interface UploadPhotoTriggerProps {
Expand All @@ -35,7 +33,6 @@ export default function UploadPhotoTrigger ({ className = '', onUploaded, childr
const sessionRef = useRef<any>()
sessionRef.current = data?.user

const { tagPhotoCmd } = usePhotoTag()
const { toMyProfile } = useReturnToProfile()

const onUploadedHannder = async (url: string): Promise<void> => {
Expand All @@ -53,12 +50,12 @@ export default function UploadPhotoTrigger ({ className = '', onUploaded, childr
// let's see if we're viewing the climb or area page
if (id != null && isValidUuid(id) && (destType === 0 || destType === 1)) {
// yes! let's tag it
await tagPhotoCmd({
mediaUrl: url,
mediaUuid: mediaUrlHash(url),
destinationId: id,
destType
})
// await tagPhotoCmd({
// mediaUrl: url,
// mediaUuid: mediaUrlHash(url),
// destinationId: id,
// destType
// })

if (onUploaded != null) onUploaded()

Expand Down
29 changes: 11 additions & 18 deletions src/components/media/AddTag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,36 @@ import { PlusIcon } from '@heroicons/react/24/outline'

import ClimbSearchForTagging from '../search/ClimbSearchForTagging'
import { EntityType, MediaWithTags, TagTargetType, TypesenseAreaType, TypesenseDocumentType } from '../../js/types'
import usePhotoTagCmd from '../../js/hooks/usePhotoTagCmd'
import { AddEntityTagProps } from '../../js/graphql/gql/tags'

interface ImageTaggerProps {
mediaWithTags: MediaWithTags
label?: JSX.Element
openSearch?: boolean
onCancel?: () => void
onAdd: (props: AddEntityTagProps) => Promise<void>
}

/**
* Allow users to tag an image, ie associate a climb with an image. Tag data will be recorded in the backend.
* @param label A button that opens the climb search
* @param imageInfo image info object
*/
export default function AddTag ({ mediaWithTags, onCancel, label, openSearch = false }: ImageTaggerProps): JSX.Element | null {
const { tagPhotoCmd } = usePhotoTagCmd()
export default function AddTag ({ mediaWithTags, onCancel, onAdd, label, openSearch = false }: ImageTaggerProps): JSX.Element | null {
return (
<ClimbSearchForTagging
onCancel={onCancel}
label={label}
openSearch={openSearch}
onSelect={async (props) => {
try {
const linkedEntityId = props.type === EntityType.climb
? (props as TypesenseDocumentType).climbUUID
: (props as TypesenseAreaType).id

await tagPhotoCmd({
mediaUuid: mediaWithTags.mediaUrl,
mediaUrl: mediaWithTags.mediaUrl,
destinationId: linkedEntityId,
destType: props.type === EntityType.climb ? TagTargetType.climb : TagTargetType.area
})
} catch (e) {
// TODO: Add friendly error message
console.log('tagging API error', e)
}
const linkedEntityId = props.type === EntityType.climb
? (props as TypesenseDocumentType).climbUUID
: (props as TypesenseAreaType).id
void onAdd({
mediaId: mediaWithTags.id,
entityId: linkedEntityId,
entityType: props.type === EntityType.climb ? TagTargetType.climb : TagTargetType.area
})
}}
/>
)
Expand Down
69 changes: 23 additions & 46 deletions src/components/media/MobileMediaCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react'

import Card from '../ui/Card/Card'
import TagList, { MobilePopupTagList } from './TagList'
Expand All @@ -15,8 +16,19 @@ export interface MobileMediaCardProps {
isAuthenticated?: boolean
}

export default function MobileMediaCard ({ header, showTagActions = false, isAuthorized = false, isAuthenticated = false, mediaWithTags }: MobileMediaCardProps): JSX.Element {
const { mediaUrl, entityTags, uploadTime } = mediaWithTags
/**
* Media card for mobile view
*/
export default function MobileMediaCard ({ header, isAuthorized = false, isAuthenticated = false, mediaWithTags }: MobileMediaCardProps): JSX.Element {
/**
* Why maintaining media object in a local state?
* Normally, this component receives tag data via props. However, when the media owner
* adds/removes tags, after the backend is updated, we also update the media object
* in Apollo cache and keep the updated state here. This way we only need to deal
* with a single media instead a large list.
*/
const [localMediaWithTags, setMedia] = useState(mediaWithTags)
const { mediaUrl, entityTags, uploadTime } = localMediaWithTags
const tagCount = entityTags.length
return (
<Card
Expand All @@ -32,7 +44,11 @@ export default function MobileMediaCard ({ header, showTagActions = false, isAut
imageActions={
<section className='flex items-center justify-between'>
<div>&nbsp;</div>
<MobilePopupTagList mediaWithTags={mediaWithTags} isAuthorized={isAuthorized} />
<MobilePopupTagList
mediaWithTags={localMediaWithTags}
isAuthorized={isAuthorized}
onChange={setMedia}
/>
</section>
}
body={
Expand All @@ -41,8 +57,10 @@ export default function MobileMediaCard ({ header, showTagActions = false, isAut
{tagCount > 0 &&
(
<TagList
mediaWithTags={mediaWithTags}
showActions={showTagActions}
mediaWithTags={localMediaWithTags}
// we have a popup for adding/removing tags
// don't show add tag button on mobile
showActions={false}
isAuthorized={isAuthorized}
isAuthenticated={isAuthenticated}
/>
Expand All @@ -56,44 +74,3 @@ export default function MobileMediaCard ({ header, showTagActions = false, isAut
/>
)
}

// interface RecentImageCardProps {
// header?: JSX.Element
// imageInfo: MediaType
// tagList: HybridMediaTag[]
// }

// export const RecentImageCard = ({ header, imageInfo, tagList }: RecentImageCardProps): JSX.Element => {
// return (
// <Card
// header={<div />}
// image={
// <img
// src={MobileLoader({
// src: imageInfo.filename,
// width: MOBILE_IMAGE_MAX_WIDITH
// })}
// width={MOBILE_IMAGE_MAX_WIDITH}
// sizes='100vw'
// />
// }
// body={
// <>
// <section className='flex flex-col gap-y-4'>
// <TagList
// list={tagList}
// showActions={false}
// isAuthorized={false}
// isAuthenticated={false}
// imageInfo={imageInfo}
// />
// <div className='uppercase text-xs text-base-200'>
// {getUploadDateSummary(imageInfo.ctime)}
// </div>

// </section>
// </>
// }
// />
// )
// }
11 changes: 6 additions & 5 deletions src/components/media/Tag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import NetworkSquareIcon from '../../assets/icons/network-square-icon.svg'

import clx from 'classnames'
import { EntityTag, TagTargetType } from '../../js/types'
import { OnDeleteCallback } from './TagList'

interface PhotoTagProps {
mediaId: string
tag: EntityTag
onDelete: (tagId: string) => void
onDelete: OnDeleteCallback
isAuthorized?: boolean
showDelete?: boolean
size?: 'md' | 'lg'
}

export default function Tag ({ tag, onDelete, size = 'md', showDelete = false, isAuthorized = false }: PhotoTagProps): JSX.Element | null {
export default function Tag ({ mediaId, tag, onDelete, size = 'md', showDelete = false, isAuthorized = false }: PhotoTagProps): JSX.Element | null {
const [url, name] = resolver(tag)
if (url == null || name == null) return null
const isArea = tag.type === TagTargetType.area
Expand All @@ -34,10 +36,9 @@ export default function Tag ({ tag, onDelete, size = 'md', showDelete = false, i
<div className='mt-0.5 whitespace-nowrap truncate text-sm'>{name}</div>
{isAuthorized && showDelete &&
<button
disabled
onClick={(e) => {
onDelete(tag.targetId)
onClick={async (e) => {
e.preventDefault()
await onDelete({ mediaId: mediaId, tagId: tag.id })
}}
title='Delete tag'
>
Expand Down
75 changes: 63 additions & 12 deletions src/components/media/TagList.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { useState, MouseEventHandler } from 'react'
import { useState, Dispatch, SetStateAction, MouseEventHandler, useEffect } from 'react'
import classNames from 'classnames'
import { TagIcon, PlusIcon } from '@heroicons/react/24/outline'
import { DropdownMenuItem as PrimitiveDropdownMenuItem } from '@radix-ui/react-dropdown-menu'
import { signIn } from 'next-auth/react'

import AddTag from './AddTag'
import { DropdownMenu, DropdownContent, DropdownTrigger, DropdownItem, DropdownSeparator } from '../ui/DropdownMenu'
import useDeleteTagBackend from '../../js/hooks/useDeleteTagBackend'
import { EntityTag, MediaWithTags } from '../../js/types'
import Tag from './Tag'
import useMediaCmd, { RemoveEntityTagProps } from '../../js/hooks/useMediaCmd'
import { AddEntityTagProps } from '../../js/graphql/gql/tags'

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

export type OnDeleteCallback = (args: RemoveEntityTagProps) => Promise<void>

interface TagsProps {
mediaWithTags: MediaWithTags
Expand All @@ -23,12 +28,39 @@ interface TagsProps {
* 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 {
const { onDelete } = useDeleteTagBackend()
if (mediaWithTags == null) {
const { addEntityTagCmd, removeEntityTagCmd } = useMediaCmd()
/**
* Why maintaining media object in a local state?
* Normally, this component receives tag data via props. However, when the media owner
* adds/removes tags, after the backend is updated, we also update the media object
* in Apollo cache and keep the updated state here. This way we only need to deal
* with a single media instead a large list.
*/
const [localMediaWithTags, setMedia] = useState(mediaWithTags)

useEffect(() => {
setMedia(mediaWithTags)
}, [mediaWithTags])

if (localMediaWithTags == null) {
return null
}

const { entityTags } = mediaWithTags
const onAddHandler: OnAddCallback = async (args) => {
const [, updatedMediaObject] = await addEntityTagCmd(args)
if (updatedMediaObject != null) {
setMedia(updatedMediaObject)
}
}

const onDeleteHandler: OnDeleteCallback = async (args) => {
const [, updatedMediaObject] = await removeEntityTagCmd(args)
if (updatedMediaObject != null) {
setMedia(updatedMediaObject)
}
}

const { entityTags, id } = localMediaWithTags

return (
<div className={
Expand All @@ -41,15 +73,17 @@ export default function TagList ({ mediaWithTags, isAuthorized = false, isAuthen
{entityTags.map((tag: EntityTag) =>
<Tag
key={`${tag.targetId}`}
mediaId={id}
tag={tag}
onDelete={onDelete}
onDelete={onDeleteHandler}
isAuthorized={isAuthorized}
showDelete={showDelete}
/>)}
{showActions && isAuthorized &&
<AddTag
mediaWithTags={mediaWithTags}
mediaWithTags={localMediaWithTags}
label={<AddTagBadge />}
onAdd={onAddHandler}
/>}
{showActions && !isAuthenticated &&
<AddTagBadge onClick={() => { void signIn('auth0') }} />}
Expand All @@ -61,15 +95,30 @@ export interface TagListProps {
mediaWithTags: MediaWithTags
isAuthorized?: boolean
children?: JSX.Element
onChange: Dispatch<SetStateAction<MediaWithTags>>
}

/**
* Mobile-first tag list wrapped in a popup menu
* Mobile tag list wrapped in a popup menu
*/
export const MobilePopupTagList: React.FC<TagListProps> = ({ mediaWithTags, isAuthorized = false }) => {
const { onDelete } = useDeleteTagBackend()
export const MobilePopupTagList: React.FC<TagListProps> = ({ mediaWithTags, isAuthorized = false, onChange }) => {
const { addEntityTagCmd, removeEntityTagCmd } = useMediaCmd()
const [openSearch, setOpenSearch] = useState(false)
const { entityTags } = mediaWithTags

const onAddHandler: OnAddCallback = async (args) => {
const [, updatedMediaObject] = await addEntityTagCmd(args)
if (updatedMediaObject != null) {
onChange(updatedMediaObject)
}
}

const onDeleteHandler: OnDeleteCallback = async (args) => {
const [, updatedMediaObject] = await removeEntityTagCmd(args)
if (updatedMediaObject != null) {
onChange(updatedMediaObject)
}
}
const { id, entityTags } = mediaWithTags
return (
<div aria-label='tag popup'>
<DropdownMenu>
Expand All @@ -81,9 +130,10 @@ export const MobilePopupTagList: React.FC<TagListProps> = ({ mediaWithTags, isAu
{entityTags.map(tag => (
<PrimitiveDropdownMenuItem key={`${tag.id}`} className='px-2 py-3'>
<Tag
mediaId={id}
tag={tag}
isAuthorized={isAuthorized}
onDelete={onDelete}
onDelete={onDeleteHandler}
showDelete
size='lg'
/>
Expand Down Expand Up @@ -111,6 +161,7 @@ export const MobilePopupTagList: React.FC<TagListProps> = ({ mediaWithTags, isAu
onCancel={() => setOpenSearch(false)}
openSearch={openSearch}
mediaWithTags={mediaWithTags}
onAdd={onAddHandler}
label={<div className='hidden' />}
/>
</div>
Expand Down
Loading

1 comment on commit a01e95b

@vercel
Copy link

@vercel vercel bot commented on a01e95b Jul 2, 2023

Choose a reason for hiding this comment

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

Please sign in to comment.