Skip to content

Commit

Permalink
fix: upload should tag climb/area automatically (#909)
Browse files Browse the repository at this point in the history
* fix: upload should tag climb/area automatically
* fix: should show 1 error msg on multiple upload failures
* refactor: remove local tag state
* fix: remove photo upload issue from broadcast msg
  • Loading branch information
vnugent committed Jul 12, 2023
1 parent 176b741 commit a29cde6
Show file tree
Hide file tree
Showing 15 changed files with 187 additions and 170 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"fuse.js": "^6.6.2",
"graphql": "^16.2.0",
"i18n-iso-countries": "^7.5.0",
"immer": "^9.0.12",
"immer": "^10.0.2",
"lexical": "^0.7.5",
"mapbox-gl": "^2.7.0",
"nanoid": "^4.0.0",
Expand Down
1 change: 0 additions & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export default function Header (props: HeaderProps): JSX.Element {
<AppAlert
message={
<>
<div className='text-sm'>• July 2023: Photo upload is working again. Known issue: you can only tag photos from your profile page.</div>
<div className='text-sm'>
• January 2023: Use this special&nbsp;
<Link href='/crag/18c5dd5c-8186-50b6-8a60-ae2948c548d1'>
Expand Down
18 changes: 4 additions & 14 deletions src/components/media/MobileMediaCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { useState } from 'react'

import Card from '../ui/Card/Card'
import TagList, { MobilePopupTagList } from './TagList'
import { MobileLoader } from '../../js/sirv/util'
Expand All @@ -20,15 +18,7 @@ export interface MobileMediaCardProps {
* 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 { mediaUrl, entityTags, uploadTime } = mediaWithTags
const tagCount = entityTags.length
return (
<Card
Expand All @@ -45,9 +35,9 @@ export default function MobileMediaCard ({ header, isAuthorized = false, isAuthe
<section className='flex items-center justify-between'>
<div>&nbsp;</div>
<MobilePopupTagList
mediaWithTags={localMediaWithTags}
mediaWithTags={mediaWithTags}
isAuthorized={isAuthorized}
onChange={setMedia}
// onChange={setMedia}
/>
</section>
}
Expand All @@ -57,7 +47,7 @@ export default function MobileMediaCard ({ header, isAuthorized = false, isAuthe
{tagCount > 0 &&
(
<TagList
mediaWithTags={localMediaWithTags}
mediaWithTags={mediaWithTags}
// we have a popup for adding/removing tags
// don't show add tag button on mobile
showActions={false}
Expand Down
2 changes: 1 addition & 1 deletion src/components/media/Tag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default function Tag ({ mediaId, tag, onDelete, size = 'md', showDelete =
<button
onClick={async (e) => {
e.preventDefault()
await onDelete({ mediaId: mediaId, tagId: tag.id })
await onDelete({ mediaId: mediaId, tagId: tag.id, entityId: tag.targetId, entityType: tag.type })
}}
title='Delete tag'
>
Expand Down
45 changes: 10 additions & 35 deletions src/components/media/TagList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, Dispatch, SetStateAction, MouseEventHandler, useEffect } from 'react'
import { useState, MouseEventHandler } from 'react'
import classNames from 'classnames'
import { TagIcon, PlusIcon } from '@heroicons/react/24/outline'
import { DropdownMenuItem as PrimitiveDropdownMenuItem } from '@radix-ui/react-dropdown-menu'
Expand Down Expand Up @@ -29,38 +29,20 @@ interface TagsProps {
*/
export default function TagList ({ mediaWithTags, isAuthorized = false, isAuthenticated = false, showDelete = false, showActions = true, className = '' }: TagsProps): JSX.Element | 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) {

if (mediaWithTags == null) {
return null
}

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

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

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

return (
<div className={
Expand All @@ -81,7 +63,7 @@ export default function TagList ({ mediaWithTags, isAuthorized = false, isAuthen
/>)}
{showActions && isAuthorized &&
<AddTag
mediaWithTags={localMediaWithTags}
mediaWithTags={mediaWithTags}
label={<AddTagBadge />}
onAdd={onAddHandler}
/>}
Expand All @@ -95,28 +77,21 @@ export interface TagListProps {
mediaWithTags: MediaWithTags
isAuthorized?: boolean
children?: JSX.Element
onChange: Dispatch<SetStateAction<MediaWithTags>>
}

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

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

const onDeleteHandler: OnDeleteCallback = async (args) => {
const [, updatedMediaObject] = await removeEntityTagCmd(args)
if (updatedMediaObject != null) {
onChange(updatedMediaObject)
}
await removeEntityTagCmd(args)
}
const { id, entityTags } = mediaWithTags
return (
Expand Down
3 changes: 2 additions & 1 deletion src/js/graphql/gql/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { gql } from '@apollo/client'
import { MediaWithTags } from '../../types'
import { AddEntityTagProps, FRAGMENT_MEDIA_WITH_TAGS } from './tags'

export type NewEmbeddedEntityTag = Omit<AddEntityTagProps, 'mediaId'>
export type NewMediaObjectInput = Pick<MediaWithTags, 'mediaUrl' | 'width' | 'height' | 'format' | 'size'> & {
userUuid: string
entityTags?: Array<Omit<AddEntityTagProps, 'mediaId'>>
entityTag?: NewEmbeddedEntityTag
}

export interface AddNewMediaObjectsArgs {
Expand Down
5 changes: 5 additions & 0 deletions src/js/graphql/gql/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ export const MUTATION_ADD_ENTITY_TAG = gql`
}
}`

export interface RemoveEntityTagMutationProps {
mediaId: string
tagId: string
}

/**
* Return type for remove entity tag mutation
*/
Expand Down
77 changes: 52 additions & 25 deletions src/js/hooks/useMediaCmd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { toast } from 'react-toastify'
import { useRouter } from 'next/router'

import { graphqlClient } from '../graphql/Client'
import { AddEntityTagProps, QUERY_USER_MEDIA, QUERY_MEDIA_BY_ID, MUTATION_ADD_ENTITY_TAG, MUTATION_REMOVE_ENTITY_TAG, GetMediaForwardQueryReturn, AddEntityTagMutationReturn, RemoveEntityTagMutationReturn } from '../graphql/gql/tags'
import { MediaWithTags, EntityTag, MediaConnection } from '../types'
import { AddNewMediaObjectsArgs, AddMediaObjectsReturn, MUTATION_ADD_MEDIA_OBJECTS, NewMediaObjectInput, DeleteOneMediaObjectArgs, DeleteOneMediaObjectReturn, MUTATION_DELETE_ONE_MEDIA_OBJECT } from '../graphql/gql/media'
import { AddEntityTagProps, QUERY_USER_MEDIA, QUERY_MEDIA_BY_ID, MUTATION_ADD_ENTITY_TAG, MUTATION_REMOVE_ENTITY_TAG, GetMediaForwardQueryReturn, AddEntityTagMutationReturn, RemoveEntityTagMutationReturn, RemoveEntityTagMutationProps } from '../graphql/gql/tags'
import { MediaWithTags, EntityTag, MediaConnection, TagTargetType } from '../types'
import { AddNewMediaObjectsArgs, AddMediaObjectsReturn, MUTATION_ADD_MEDIA_OBJECTS, NewMediaObjectInput, DeleteOneMediaObjectArgs, DeleteOneMediaObjectReturn, MUTATION_DELETE_ONE_MEDIA_OBJECT, NewEmbeddedEntityTag } from '../graphql/gql/media'
import { useUserGalleryStore } from '../stores/useUserGalleryStore'
import { deleteMediaFromStorage } from '../userApi/media'

Expand All @@ -24,9 +24,9 @@ interface FetchMoreMediaForwardProps {
first?: number
after?: string
}
export interface RemoveEntityTagProps {
mediaId: string
tagId: string
export interface RemoveEntityTagProps extends RemoveEntityTagMutationProps{
entityId: string
entityType: TagTargetType
}

type FetchMoreMediaForwardCmd = (args: FetchMoreMediaForwardProps) => Promise<MediaConnection | null>
Expand Down Expand Up @@ -105,29 +105,31 @@ export default function useMediaCmd (): UseMediaCmdReturn {
MUTATION_ADD_MEDIA_OBJECTS, {
client: graphqlClient,
errorPolicy: 'none',
onError: error => toast.error(error.message),
onCompleted: (data) => {
onError: console.error,
onCompleted: async (data) => {
/**
* Now update the data store to trigger UserGallery re-rendering.
*/
data.addMediaObjects.forEach(media => {
addNewMediaToUserGallery({
edges: [
{
node: media,
/**
await Promise.all(
data.addMediaObjects.map(async media => {
await getMediaById(media.id)
addNewMediaToUserGallery({
edges: [
{
node: media,
/**
* We don't care about setting cursor because newer images are added to the front
* of the list.
*/
cursor: ''
cursor: ''
}
],
pageInfo: {
hasNextPage: true,
endCursor: '' // not supported
}
],
pageInfo: {
hasNextPage: true,
endCursor: '' // not supported
}
})
})
})
}))
}
}
)
Expand Down Expand Up @@ -167,6 +169,7 @@ export default function useMediaCmd (): UseMediaCmdReturn {
}
await deleteMediaFromStorage(mediaUrl)
deleteMediaFromUserGallery(mediaId)
toast.success('Photo deleted.')
return true
} catch {
toast.error('Cannot delete media. Please try again.')
Expand All @@ -190,7 +193,7 @@ export default function useMediaCmd (): UseMediaCmdReturn {
*/
const addEntityTagCmd: AddEntityTagCmd = async (args: AddEntityTagProps) => {
try {
const { mediaId } = args
const { mediaId, entityId, entityType } = args
const res = await addEntityTagGQL({
variables: args,
context: apolloClientContext
Expand All @@ -201,14 +204,15 @@ export default function useMediaCmd (): UseMediaCmdReturn {

if (mediaRes != null) {
updateOneMediaUserGallery(mediaRes)
await invalidatePageWithEntity({ entityId, entityType })
}
return [res.data?.addEntityTag ?? null, mediaRes]
} catch {
return [null, null]
}
}

const [removeEntityTagGQL] = useMutation<RemoveEntityTagMutationReturn, RemoveEntityTagProps>(
const [removeEntityTagGQL] = useMutation<RemoveEntityTagMutationReturn, RemoveEntityTagMutationProps>(
MUTATION_REMOVE_ENTITY_TAG, {
client: graphqlClient,
onCompleted: () => toast.success('Tag removed.'),
Expand All @@ -221,7 +225,7 @@ export default function useMediaCmd (): UseMediaCmdReturn {
/**
* Remove an entity tag from a media
*/
const removeEntityTagCmd: RemoveEntityTagCmd = async ({ mediaId, tagId }) => {
const removeEntityTagCmd: RemoveEntityTagCmd = async ({ mediaId, tagId, entityId, entityType }) => {
try {
const res = await removeEntityTagGQL({
variables: {
Expand All @@ -239,6 +243,7 @@ export default function useMediaCmd (): UseMediaCmdReturn {

if (mediaRes != null) {
updateOneMediaUserGallery(mediaRes)
await invalidatePageWithEntity({ entityId, entityType })
}

return [res.data?.removeEntityTag ?? false, mediaRes]
Expand All @@ -256,3 +261,25 @@ export default function useMediaCmd (): UseMediaCmdReturn {
removeEntityTagCmd
}
}

/**
* Request Nextjs to re-generate props for target page when adding or removing a tag.
*/
const invalidatePageWithEntity = async ({ entityId, entityType }: NewEmbeddedEntityTag): Promise<void> => {
let url: string
switch (entityType) {
case TagTargetType.climb:
url = `/api/revalidate?c=${entityId}`
break
case TagTargetType.area:
url = `/api/revalidate?s=${entityId}`
break
}
if (url != null) {
try {
await fetch(url)
} catch (e) {
console.log(e)
}
}
}
Loading

1 comment on commit a29cde6

@vercel
Copy link

@vercel vercel bot commented on a29cde6 Jul 12, 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.