Skip to content

Commit

Permalink
fix: restore photo upload (#902)
Browse files Browse the repository at this point in the history
* restore tagging feature
* wire api with infinity scroll component
* refactor: consolidate media pagination/tagging in one hook
* refactor: get rid of zustood store for tags. use zustand directly.
* refactor: use apollo local cache for tags in edit mode
* refactor: connect photo upload to GCloud bucket & backend api
* refactor: introduce devtools middleware for debbuging
  • Loading branch information
vnugent authored Jul 9, 2023
1 parent c2b5238 commit 2d899d8
Show file tree
Hide file tree
Showing 25 changed files with 592 additions and 674 deletions.
5 changes: 4 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,7 @@ NEXT_PUBLIC_MAPBOX_API_KEY=pk.eyJ1IjoibWFwcGFuZGFzIiwiYSI6ImNsZG1wcnBhZTA5eXozb3
OPEN_COLLECTIVE_API_URI=https://api.opencollective.com/graphql/v2

# A comma-separate-list of profiles to pre-build
PREBUILD_PROFILES=
PREBUILD_PROFILES=

# Google cloud storage bucket name
GC_BUCKET_NAME=openbeta-staging
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@google-cloud/storage": "^6.9.5",
"@google-cloud/storage": "^6.11.0",
"@headlessui/react": "^1.6.4",
"@heroicons/react": "2.0.13",
"@lexical/react": "^0.7.5",
Expand Down Expand Up @@ -71,7 +71,7 @@
"underscore": "^1.13.3",
"uuid": "9.0.0",
"yup": "^0.32.9",
"zustand": "^3.7.1"
"zustand": "^4.3.9"
},
"keywords": [
"rock climbing",
Expand Down
2 changes: 1 addition & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function Header (props: HeaderProps): JSX.Element {
<AppAlert
message={
<>
<div className='text-sm'>May 2023: Photo sharing and tagging is temporarily disabled while we're upgrading our media storage.</div>
<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
88 changes: 4 additions & 84 deletions src/components/UploadPhotoTrigger.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { useRef } from 'react'
import { useSession, signIn } from 'next-auth/react'
import { useRouter, NextRouter } from 'next/router'
import { validate as isValidUuid } from 'uuid'
import clx from 'classnames'

import usePhotoUploader from '../js/hooks/usePhotoUploader'
import { userMediaStore, revalidateUserHomePage } from '../js/stores/media'
import useReturnToProfile from '../js/hooks/useReturnToProfile'
import { BlockingAlert } from './ui/micro/AlertDialogue'
import { useUserGalleryStore } from '../js/stores/useUserGalleryStore'

interface UploadPhotoTriggerProps {
children: JSX.Element | JSX.Element []
Expand All @@ -22,7 +18,6 @@ interface UploadPhotoTriggerProps {
*/
export default function UploadPhotoTrigger ({ className = '', onUploaded, children }: UploadPhotoTriggerProps): JSX.Element | null {
const { data, status } = useSession()
const router = useRouter()

/**
* Why useRef?
Expand All @@ -33,58 +28,12 @@ export default function UploadPhotoTrigger ({ className = '', onUploaded, childr
const sessionRef = useRef<any>()
sessionRef.current = data?.user

const { toMyProfile } = useReturnToProfile()

const onUploadedHannder = async (url: string): Promise<void> => {
const session = sessionRef.current

if (session.metadata == null) {
console.log('## Error: user metadata not found')
return
}

const { nick, uuid } = session.metadata

const [id, destType, pageToInvalidate, destPageUrl] = pagePathToEntityType(router)

// 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
// })

if (onUploaded != null) onUploaded()

// Tell Next to regenerate the page being tagged
try {
await fetch(pageToInvalidate)
} catch (e) { console.log(e) }

// Regenerate user profile page as well
if (nick != null) {
void revalidateUserHomePage(nick)
}

// Very important call to force destination page to update its props
// without doing a hard refresh
void router.replace(destPageUrl)
} else {
if (uuid != null && nick != null) {
await toMyProfile()
await userMediaStore.set.addImage(nick, uuid, url, true)
}
}
}

const { uploading, getRootProps, getInputProps, openFileDialog } = usePhotoUploader({ onUploaded: onUploadedHannder })
const { getRootProps, getInputProps, openFileDialog } = usePhotoUploader()
const uploading = useUserGalleryStore(store => store.uploading)

return (
<div
className={clx('pointer-events-none cursor-not-allowed', className, uploading ? 'pointer-events-none' : '')} {...getRootProps()} onClick={(e) => {
className={clx(className, uploading ? 'pointer-events-none' : '')} {...getRootProps()} onClick={(e) => {
if (status === 'authenticated' && !uploading) {
openFileDialog()
} else {
Expand All @@ -94,35 +43,6 @@ export default function UploadPhotoTrigger ({ className = '', onUploaded, childr
>
<input {...getInputProps()} />
{children}
{uploading &&
<BlockingAlert
title='Uploading'
description={<progress className='progress w-56' />}
/>}
</div>
)
}

/**
* Convert current page path to a destination type for tagging. Expect `path` to be in /areas|crag|climb/[id].
* @param path `path` property as return from `Next.router()`
*/
const pagePathToEntityType = (router: NextRouter): [string, number, string, string] | [null, null, null, null] => {
const nulls: [null, null, null, null] = [null, null, null, null]
const { asPath, query } = router
const tokens = asPath.split('/')

if (query == null || query.id == null || tokens.length < 3) return nulls

const id = query.id as string
switch (tokens[1]) {
case 'climbs':
return [id, 0, `/api/revalidate?c=${id}`, `/climbs/${id}`]
case 'areas':
return [id, 1, `/api/revalidate?s=${id}`, `/crag/${id}?${Date.now()}`]
case 'crag':
return [id, 1, `/api/revalidate?s=${id}`, `/crag/${id}?${Date.now()}`]
default:
return nulls
}
}
17 changes: 3 additions & 14 deletions src/components/media/PhotoUploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,17 @@ import usePhotoUploader from '../../js/hooks/usePhotoUploader'
interface PhotoUploaderProps {
className: string
children: JSX.Element | JSX.Element []
onUploaded: (url: string) => Promise<void>
}

/** A drop-zone for uploading photos, with click-to-open a file explorer operation */
export default function PhotoUploader ({ className, onUploaded, children }: PhotoUploaderProps): JSX.Element {
const { uploading, getRootProps, getInputProps } = usePhotoUploader({ onUploaded })

export default function BaseUploader ({ className, children }: PhotoUploaderProps): JSX.Element {
const { getRootProps, getInputProps } = usePhotoUploader()
return (
// Fiddling with syntax here seems to make dropzone clicking work.
// (tested both FF and Chrome on Ubuntu)
<div {...getRootProps({ className: `pointer-events-none cursor-not-allowed dropzone ${className}` })}>
{uploading && <Progress />}
<div {...getRootProps({ className: `dropzone ${className}` })}>
<input {...getInputProps()} />
{children}
</div>
)
}

const Progress = (): JSX.Element => (
<div className='absolute top-0 bg-gray-100 w-full h-full flex items-center justify-center bg-opacity-90'>
<span
className='px-2 py-1 bg-gray-800 text-primary-contrast text-md font-semibold animate-pulse rounded-lg'
>Loading...
</span>
</div>)
17 changes: 3 additions & 14 deletions src/components/media/RemoveImage.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,20 @@
import { useSession } from 'next-auth/react'
import { actions } from '../../js/stores'
import { removePhoto } from '../../js/userApi/media'
import { MediaWithTags } from '../../js/types'
import AlertDialogue from '../ui/micro/AlertDialogue'
import { DefaultLoader } from '../../js/sirv/util'
import useMediaCmd from '../../js/hooks/useMediaCmd'

interface RemoveImageProps {
imageInfo: MediaWithTags
}

export default function RemoveImage ({ imageInfo }: RemoveImageProps): JSX.Element | null {
const { data } = useSession()
const { deleteOneMediaObjectCmd } = useMediaCmd()

const { entityTags } = imageInfo
if (entityTags.length > 0) return null

const remove = async (): Promise<void> => {
if (data?.user?.metadata == null) {
console.warn('## Error: user metadata not found')
return
}

const filename: string = imageInfo.mediaUrl
const isRemoved = await removePhoto(filename)
if (isRemoved != null) {
await actions.media.removeImage(imageInfo.mediaUrl)
}
await deleteOneMediaObjectCmd(imageInfo.id, imageInfo.mediaUrl)
}

return (
Expand Down
23 changes: 9 additions & 14 deletions src/components/media/UploadCTA.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,9 @@ import { CameraIcon, ArrowRightIcon } from '@heroicons/react/24/outline'
import clx from 'classnames'

import UploadPhotoTrigger from '../UploadPhotoTrigger'
import PhotoUploader from './PhotoUploader'
import BaseUploader from './PhotoUploader'
import Link from 'next/link'

interface UploadCTAProps {
onUploadFinish: (url: string) => Promise<void>
}

/**
* A photo upload Call-to-action button
*
Expand All @@ -23,20 +19,19 @@ interface UploadCTAProps {
* If a user selects / drags multiple, they should be all be uploaded (weather sequentially
* or asynchronously).
*/
export default function UploadCTA ({ onUploadFinish }: UploadCTAProps): JSX.Element {
export default function UploadCTA (): JSX.Element {
return (
<PhotoUploader
onUploaded={onUploadFinish}
<BaseUploader
className='relative aspect-video mt-8 md:mt-0 lg:aspect-auto
lg:w-[300px] lg:h-[300px] rounded-lg bg-neutral-200 border-neutral-300
border-2 border-dashed flex items-center justify-center cursor-pointer
hover:brightness-75 overflow-hidden'
lg:w-[300px] lg:h-[300px] rounded-box
border-2 border-base-content/80 border-dashed flex items-center justify-center cursor-pointer
overflow-hidden'
>
<div className='flex flex-col items-center'>
<CameraIcon className='stroke-gray-400 stroke-1 w-24 h-24' />
<span className='text-secondary text-sm'>Click to upload</span>
<CameraIcon className='w-24 h-24 text-base-content/80' />
<span className='text-base-content text-sm'>Click to upload</span>
</div>
</PhotoUploader>
</BaseUploader>
)
}

Expand Down
46 changes: 27 additions & 19 deletions src/components/media/UserGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@ import InfiniteScroll from 'react-infinite-scroll-component'

import UserMedia from './UserMedia'
import MobileMediaCard from './MobileMediaCard'
import { MediaConnection } from '../../js/types'
import UploadCTA from './UploadCTA'
import { actions } from '../../js/stores'
import SlideViewer from './slideshow/SlideViewer'
import { TinyProfile } from '../users/PublicProfile'
import { UserPublicPage } from '../../js/types/User'
import { useResponsive } from '../../js/hooks'
import TagList from './TagList'
import usePermissions from '../../js/hooks/auth/usePermissions'
import useMediaCmd from '../../js/hooks/useMediaCmd'
import { useUserGalleryStore } from '../../js/stores/useUserGalleryStore'

export interface UserGalleryProps {
uid: string
Expand All @@ -37,9 +36,6 @@ export interface UserGalleryProps {
* 1. Component will start with the most recent 6 (see A.1 above)
* 2. When the user scrolls down, fetch the next 6 (cache hit)
*
* C. Image upload and delete are currently disabled. We'll need to update Apollo cache
* like how do with tags.
*
* Simplifying component Todos:
* - simplify back button logic with Next Layout in v13
*
Expand Down Expand Up @@ -75,7 +71,24 @@ export default function UserGallery ({ uid, postId: initialPostId, userPublicPag
return true
})

const [mediaConnection, setMediaConnection] = useState<MediaConnection>(userPublicPage.media.mediaConnection)
const mediaConnection = useUserGalleryStore((state) => state.mediaConnection)
const resetData = useUserGalleryStore((state) => state.reset)
const appendMore = useUserGalleryStore((state) => state.append)

/**
* Initialize image data store
*/
useEffect(() => {
if (isAuthorized) {
void fetchMoreMediaForward({
userUuid: userPublicPage.profile.userUuid
}).then(nextMediaConnection => {
if (nextMediaConnection != null) resetData(nextMediaConnection)
})
} else {
resetData(userPublicPage.media.mediaConnection)
}
}, [userPublicPage.media.mediaConnection])

const imageList = mediaConnection.edges.map(edge => edge.node)

Expand All @@ -99,10 +112,6 @@ export default function UserGallery ({ uid, postId: initialPostId, userPublicPag
}
}, [initialPostId, imageList, router])

const onUploadHandler = async (imageUrl: string): Promise<void> => {
await actions.media.addImage(uid, userProfile.userUuid, imageUrl, true)
}

const imageOnClickHandler = useCallback(async (props: any): Promise<void> => {
if (isMobile) return
await navigateHandler(props.index)
Expand All @@ -128,7 +137,10 @@ export default function UserGallery ({ uid, postId: initialPostId, userPublicPag

// to load more images when user scrolls to the 'scrollThreshold' value of the page
const fetchMoreData = async (): Promise<void> => {
const lastCursor = mediaConnection.edges[mediaConnection.edges.length - 1].cursor
let lastCursor: string | undefined
if (mediaConnection.edges.length > 0) {
lastCursor = mediaConnection.edges[mediaConnection.edges.length - 1].cursor
}
const nextMediaConnection = await fetchMoreMediaForward({
userUuid: userPublicPage?.profile.userUuid,
after: lastCursor
Expand All @@ -138,11 +150,7 @@ export default function UserGallery ({ uid, postId: initialPostId, userPublicPag
return
}

setMediaConnection(curr => ({
edges: curr.edges.concat(nextMediaConnection.edges),
pageInfo: nextMediaConnection.pageInfo
})
)
appendMore(nextMediaConnection)
}

// When logged-in user has fewer than 3 photos,
Expand All @@ -160,8 +168,8 @@ export default function UserGallery ({ uid, postId: initialPostId, userPublicPag
loader={null}
>
<div className='flex flex-col gap-x-6 gap-y-10 sm:gap-6 sm:grid sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 2xl:grid-cols-4'>
{imageList?.length >= 3 && isAuthorized && <UploadCTA key={-1} onUploadFinish={onUploadHandler} />}
{mediaConnection.edges.map((edge, index) => {
{imageList?.length >= 3 && isAuthorized && <UploadCTA key={-1} />}
{mediaConnection.edges.map((edge, index: number) => {
const mediaWithTags = edge.node
const { mediaUrl, entityTags } = mediaWithTags
const key = `${mediaUrl}${index}`
Expand Down Expand Up @@ -205,7 +213,7 @@ export default function UserGallery ({ uid, postId: initialPostId, userPublicPag
)
})}
{placeholders.map(index =>
<UploadCTA key={index} onUploadFinish={onUploadHandler} />)}
<UploadCTA key={index} />)}
</div>
</InfiniteScroll>

Expand Down
Loading

1 comment on commit 2d899d8

@vercel
Copy link

@vercel vercel bot commented on 2d899d8 Jul 9, 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.