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

refactor: restore tagging feature #869

Merged
merged 8 commits into from
Jul 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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