diff --git a/.env b/.env index f1d98ca90..8cc8e7078 100644 --- a/.env +++ b/.env @@ -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= \ No newline at end of file +PREBUILD_PROFILES= + +# Google cloud storage bucket name +GC_BUCKET_NAME=openbeta-staging diff --git a/package.json b/package.json index 940efda08..1001473be 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e536ff25c..431b1d183 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -33,7 +33,7 @@ export default function Header (props: HeaderProps): JSX.Element { -
• May 2023: Photo sharing and tagging is temporarily disabled while we're upgrading our media storage.
+
• July 2023: Photo upload is working again. Known issue: you can only tag photos from your profile page.
• January 2023: Use this special  diff --git a/src/components/UploadPhotoTrigger.tsx b/src/components/UploadPhotoTrigger.tsx index c5e76ebaa..38c8e077a 100644 --- a/src/components/UploadPhotoTrigger.tsx +++ b/src/components/UploadPhotoTrigger.tsx @@ -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 [] @@ -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? @@ -33,58 +28,12 @@ export default function UploadPhotoTrigger ({ className = '', onUploaded, childr const sessionRef = useRef() sessionRef.current = data?.user - const { toMyProfile } = useReturnToProfile() - - const onUploadedHannder = async (url: string): Promise => { - 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 (
{ + className={clx(className, uploading ? 'pointer-events-none' : '')} {...getRootProps()} onClick={(e) => { if (status === 'authenticated' && !uploading) { openFileDialog() } else { @@ -94,35 +43,6 @@ export default function UploadPhotoTrigger ({ className = '', onUploaded, childr > {children} - {uploading && - } - />}
) } - -/** - * 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 - } -} diff --git a/src/components/media/PhotoUploader.tsx b/src/components/media/PhotoUploader.tsx index 30e56d154..7d59b1fd8 100644 --- a/src/components/media/PhotoUploader.tsx +++ b/src/components/media/PhotoUploader.tsx @@ -4,28 +4,17 @@ import usePhotoUploader from '../../js/hooks/usePhotoUploader' interface PhotoUploaderProps { className: string children: JSX.Element | JSX.Element [] - onUploaded: (url: string) => Promise } /** 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) -
- {uploading && } +
{children}
) } - -const Progress = (): JSX.Element => ( -
- Loading... - -
) diff --git a/src/components/media/RemoveImage.tsx b/src/components/media/RemoveImage.tsx index 3678f9c9e..d4a964d69 100644 --- a/src/components/media/RemoveImage.tsx +++ b/src/components/media/RemoveImage.tsx @@ -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 => { - 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 ( diff --git a/src/components/media/UploadCTA.tsx b/src/components/media/UploadCTA.tsx index 16a6f4868..74185e720 100644 --- a/src/components/media/UploadCTA.tsx +++ b/src/components/media/UploadCTA.tsx @@ -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 -} - /** * A photo upload Call-to-action button * @@ -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 ( -
- - Click to upload + + Click to upload
-
+ ) } diff --git a/src/components/media/UserGallery.tsx b/src/components/media/UserGallery.tsx index b1892e0ea..3a1a0c9f9 100644 --- a/src/components/media/UserGallery.tsx +++ b/src/components/media/UserGallery.tsx @@ -6,9 +6,7 @@ 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' @@ -16,6 +14,7 @@ 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 @@ -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 * @@ -75,7 +71,24 @@ export default function UserGallery ({ uid, postId: initialPostId, userPublicPag return true }) - const [mediaConnection, setMediaConnection] = useState(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) @@ -99,10 +112,6 @@ export default function UserGallery ({ uid, postId: initialPostId, userPublicPag } }, [initialPostId, imageList, router]) - const onUploadHandler = async (imageUrl: string): Promise => { - await actions.media.addImage(uid, userProfile.userUuid, imageUrl, true) - } - const imageOnClickHandler = useCallback(async (props: any): Promise => { if (isMobile) return await navigateHandler(props.index) @@ -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 => { - 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 @@ -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, @@ -160,8 +168,8 @@ export default function UserGallery ({ uid, postId: initialPostId, userPublicPag loader={null} >
- {imageList?.length >= 3 && isAuthorized && } - {mediaConnection.edges.map((edge, index) => { + {imageList?.length >= 3 && isAuthorized && } + {mediaConnection.edges.map((edge, index: number) => { const mediaWithTags = edge.node const { mediaUrl, entityTags } = mediaWithTags const key = `${mediaUrl}${index}` @@ -205,7 +213,7 @@ export default function UserGallery ({ uid, postId: initialPostId, userPublicPag ) })} {placeholders.map(index => - )} + )}
diff --git a/src/components/media/__tests__/UserGallery.tsx b/src/components/media/__tests__/UserGallery.tsx index 0bbc92dae..7fc22f9f5 100644 --- a/src/components/media/__tests__/UserGallery.tsx +++ b/src/components/media/__tests__/UserGallery.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event' import type UserGalleryType from '../UserGallery' import { userMedia } from './data' import { UserPublicPage } from '../../../js/types/User' +import { UseMediaCmdReturn } from '../../../js/hooks/useMediaCmd' jest.mock('next/router') @@ -18,11 +19,13 @@ jest.mock('../UploadCTA', () => ({ default: jest.fn() })) -const fetchMore = jest.fn() +const fetchMoreMediaForward = jest.fn() + +fetchMoreMediaForward.mockResolvedValueOnce(userMedia.mediaConnection) jest.mock('../../../js/hooks/useMediaCmd', () => ({ __esModule: true, - default: () => ({ fetchMore }) + default: (): Partial => ({ fetchMoreMediaForward }) })) const useResponsive = jest.requireMock('../../../js/hooks/useResponsive') @@ -70,7 +73,7 @@ describe('Image gallery', () => { userPublicPage={userProfile} />) - const images = screen.getAllByRole('img') + const images = await screen.findAllByRole('img') expect(images.length).toBe(userMedia.mediaConnection.edges.length) await user.click(images[0]) // click on the first image diff --git a/src/components/media/__tests__/data.ts b/src/components/media/__tests__/data.ts index a3e3efa4f..14a8bd5e7 100644 --- a/src/components/media/__tests__/data.ts +++ b/src/components/media/__tests__/data.ts @@ -1,5 +1,5 @@ import { v4 } from 'uuid' -import { MediaWithTags, UserMedia } from '../../../js/types' +import { MediaFormat, MediaWithTags, UserMedia } from '../../../js/types' export const mediaList: MediaWithTags[] = [ { @@ -7,7 +7,7 @@ export const mediaList: MediaWithTags[] = [ mediaUrl: '/img1.jpg', width: 1200, height: 960, - format: 'jpeg', + format: MediaFormat.jpg, size: 30000, uploadTime: new Date(), entityTags: [] @@ -16,7 +16,7 @@ export const mediaList: MediaWithTags[] = [ id: v4(), width: 1200, height: 960, - format: 'jpeg', + format: MediaFormat.jpg, size: 30000, uploadTime: new Date(), entityTags: [], @@ -26,7 +26,7 @@ export const mediaList: MediaWithTags[] = [ id: v4(), width: 1200, height: 960, - format: 'jpeg', + format: MediaFormat.jpg, size: 30000, uploadTime: new Date(), entityTags: [], @@ -36,7 +36,7 @@ export const mediaList: MediaWithTags[] = [ id: v4(), width: 1200, height: 960, - format: 'jpeg', + format: MediaFormat.jpg, size: 30000, uploadTime: new Date(), entityTags: [], @@ -46,7 +46,7 @@ export const mediaList: MediaWithTags[] = [ id: v4(), width: 1200, height: 960, - format: 'jpeg', + format: MediaFormat.jpg, size: 30000, uploadTime: new Date(), entityTags: [], @@ -56,7 +56,7 @@ export const mediaList: MediaWithTags[] = [ id: v4(), width: 1200, height: 960, - format: 'jpeg', + format: MediaFormat.jpg, size: 30000, uploadTime: new Date(), entityTags: [], diff --git a/src/js/graphql/gql/media.ts b/src/js/graphql/gql/media.ts new file mode 100644 index 000000000..e4b776477 --- /dev/null +++ b/src/js/graphql/gql/media.ts @@ -0,0 +1,41 @@ +import { gql } from '@apollo/client' +import { MediaWithTags } from '../../types' +import { AddEntityTagProps, FRAGMENT_MEDIA_WITH_TAGS } from './tags' + +export type NewMediaObjectInput = Pick & { + userUuid: string + entityTags?: Array> +} + +export interface AddNewMediaObjectsArgs { + mediaList: NewMediaObjectInput[] +} + +export interface AddMediaObjectsReturn { + addMediaObjects: MediaWithTags[] +} + +export const MUTATION_ADD_MEDIA_OBJECTS = gql` +${FRAGMENT_MEDIA_WITH_TAGS} + mutation addMediaObjects($mediaList: [NewMediaObjectInput]) { + addMediaObjects( + input: $mediaList + ) { + ... MediaWithTagsFields + } + }` + +export interface DeleteOneMediaObjectArgs { + mediaId: string +} + +export interface DeleteOneMediaObjectReturn { + deleteMediaObject: boolean +} + +export const MUTATION_DELETE_ONE_MEDIA_OBJECT = gql` + mutation deleteMediaObject($mediaId: ID!) { + deleteMediaObject( + input: { mediaId: $mediaId} + ) + }` diff --git a/src/js/hooks/useMediaCmd.tsx b/src/js/hooks/useMediaCmd.tsx index f10c69a18..0f332fd0e 100644 --- a/src/js/hooks/useMediaCmd.tsx +++ b/src/js/hooks/useMediaCmd.tsx @@ -6,18 +6,23 @@ 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 { useUserGalleryStore } from '../stores/useUserGalleryStore' +import { deleteMediaFromStorage } from '../userApi/media' export interface UseMediaCmdReturn { addEntityTagCmd: AddEntityTagCmd removeEntityTagCmd: RemoveEntityTagCmd - getMediaById: (id: string) => Promise + getMediaById: GetMediaByIdCmd + addMediaObjectsCmd: AddMediaObjectsCmd + deleteOneMediaObjectCmd: DeleteOneMediaObjectCmd fetchMoreMediaForward: FetchMoreMediaForwardCmd } interface FetchMoreMediaForwardProps { userUuid: string first?: number - after: string + after?: string } export interface RemoveEntityTagProps { mediaId: string @@ -25,9 +30,11 @@ export interface RemoveEntityTagProps { } type FetchMoreMediaForwardCmd = (args: FetchMoreMediaForwardProps) => Promise - type AddEntityTagCmd = (props: AddEntityTagProps) => Promise<[EntityTag | null, MediaWithTags | null]> type RemoveEntityTagCmd = (args: RemoveEntityTagProps) => Promise<[boolean, MediaWithTags | null]> +type GetMediaByIdCmd = (id: string) => Promise +type AddMediaObjectsCmd = (mediaList: NewMediaObjectInput[]) => Promise +type DeleteOneMediaObjectCmd = (mediaId: string, mediaUrl: string) => Promise /** * A React hook for handling media tagging operations. @@ -41,6 +48,16 @@ export default function useMediaCmd (): UseMediaCmdReturn { const session = useSession() const router = useRouter() + const apolloClientContext = { + headers: { + authorization: `Bearer ${session.data?.accessToken ?? ''}` + } + } + + const addNewMediaToUserGallery = useUserGalleryStore(set => set.addToFront) + const updateOneMediaUserGallery = useUserGalleryStore(set => set.updateOne) + const deleteMediaFromUserGallery = useUserGalleryStore(set => set.delete) + const [fetchMoreMediaGQL] = useLazyQuery( QUERY_USER_MEDIA, { client: graphqlClient, @@ -75,7 +92,7 @@ export default function useMediaCmd (): UseMediaCmdReturn { * @param id media object Id * @returns MediaWithTags object. `null` if not found. */ - const getMediaById = async (id: string): Promise => { + const getMediaById: GetMediaByIdCmd = async (id) => { try { const res = await getMediaByIdGGL({ variables: { id } }) return res.data?.media ?? null @@ -84,6 +101,79 @@ export default function useMediaCmd (): UseMediaCmdReturn { } } + const [addMediaObjects] = useMutation( + MUTATION_ADD_MEDIA_OBJECTS, { + client: graphqlClient, + errorPolicy: 'none', + onError: error => toast.error(error.message), + onCompleted: (data) => { + /** + * Now update the data store to trigger UserGallery re-rendering. + */ + data.addMediaObjects.forEach(media => { + addNewMediaToUserGallery({ + edges: [ + { + node: media, + /** + * We don't care about setting cursor because newer images are added to the front + * of the list. + */ + cursor: '' + } + ], + pageInfo: { + hasNextPage: true, + endCursor: '' // not supported + } + }) + }) + } + } + ) + + const addMediaObjectsCmd: AddMediaObjectsCmd = async (mediaList) => { + const res = await addMediaObjects({ + variables: { + mediaList + }, + context: apolloClientContext + }) + return res.data?.addMediaObjects ?? null + } + + const [deleteOneMediaObject] = useMutation( + MUTATION_DELETE_ONE_MEDIA_OBJECT, { + client: graphqlClient, + errorPolicy: 'none', + onError: console.error + }) + + /** + * Delete media object from the backend and media storage + * @param mediaId + * @param mediaUrl + */ + const deleteOneMediaObjectCmd: DeleteOneMediaObjectCmd = async (mediaId, mediaUrl) => { + try { + const res = await deleteOneMediaObject({ + variables: { + mediaId + }, + context: apolloClientContext + }) + if (res.errors != null) { + throw new Error('Unexpected API error.') + } + await deleteMediaFromStorage(mediaUrl) + deleteMediaFromUserGallery(mediaId) + return true + } catch { + toast.error('Cannot delete media. Please try again.') + return false + } + } + const [addEntityTagGQL] = useMutation( MUTATION_ADD_ENTITY_TAG, { client: graphqlClient, @@ -103,16 +193,15 @@ export default function useMediaCmd (): UseMediaCmdReturn { const { mediaId } = args const res = await addEntityTagGQL({ variables: args, - context: { - headers: { - authorization: `Bearer ${session.data?.accessToken ?? ''}` - } - } + context: apolloClientContext }) // refetch the media object to update local cache const mediaRes = await getMediaById(mediaId) + if (mediaRes != null) { + updateOneMediaUserGallery(mediaRes) + } return [res.data?.addEntityTag ?? null, mediaRes] } catch { return [null, null] @@ -139,20 +228,31 @@ export default function useMediaCmd (): UseMediaCmdReturn { mediaId, tagId }, - context: { - headers: { - authorization: `Bearer ${session.data?.accessToken ?? ''}` - } - } + context: apolloClientContext }) + if (res.errors != null) { + throw new Error('Unexpected API error.') + } // refetch the media object to update local cache const mediaRes = await getMediaById(mediaId) + + if (mediaRes != null) { + updateOneMediaUserGallery(mediaRes) + } + return [res.data?.removeEntityTag ?? false, mediaRes] } catch { return [false, null] } } - return { fetchMoreMediaForward, getMediaById, addEntityTagCmd, removeEntityTagCmd } + return { + fetchMoreMediaForward, + getMediaById, + addMediaObjectsCmd, + deleteOneMediaObjectCmd, + addEntityTagCmd, + removeEntityTagCmd + } } diff --git a/src/js/hooks/usePhotoUploader.ts b/src/js/hooks/usePhotoUploader.ts deleted file mode 100644 index fbbedc9e5..000000000 --- a/src/js/hooks/usePhotoUploader.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { useState } from 'react' -import { useDropzone, DropzoneInputProps, FileRejection } from 'react-dropzone' -import { toast } from 'react-toastify' - -import { userMediaStore } from '../stores/media' -import { uploadPhoto } from '../userApi/media' - -interface UploaderProps { - /** Called after a succesful upload */ - onUploaded: (url: string) => Promise -} - -interface PhotoUploaderReturnType { - uploading: boolean - getInputProps: (props?: T) => T - getRootProps: (props?: T) => T - openFileDialog: () => void -} - -async function readFile (file: File): Promise> { - return await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onabort = () => reject(new Error('file reading was aborted')) - reader.onerror = () => reject(new Error('file reading has failed')) - reader.onload = async (event) => resolve(event) - // Starts reading the contents of the specified Blob, once finished, - // the result attribute contains an ArrayBuffer representing the file's data. - reader.readAsArrayBuffer(file) - }) -} - -/** - * Hook providing logic for handling all things photo-upload. - * Essential logic for handling file data and uploading it to the provider - * is all encapsulated here, as well as some other api shorthand. - * */ -export default function usePhotoUploader ({ onUploaded }: UploaderProps): PhotoUploaderReturnType { - const [uploading, setUploading] = useState(false) - - /** When a file is loaded by the browser (as in, loaded from the local filesystem, - * not loaded from a webserver) we can begin to upload the bytedata to the provider */ - const onload = async (event: ProgressEvent, filename: string): Promise => { - if (event.target === null || event.target.result === null) return // guard this - - // Do whatever you want with the file contents - let imageData = event.target.result - if (typeof imageData === 'string') { - imageData = Buffer.from(imageData) - } - - try { - const url = await uploadPhoto(filename, imageData) - void onUploaded(url) - } catch (e) { - console.log('#upload error', e) - await userMediaStore.set.setPhotoUploadErrorMessage('Failed to upload: Exceeded retry limit.') - } - } - - const onDrop = async (files: File[], rejections: FileRejection[]): Promise => { - console.log('#number of files', files.length) - if (rejections.length > 0) { console.warn('Rejected files: ', rejections) } - - // Do something with the files - setUploading(true) - - for (const file of files) { - if (file.size > 11534336) { - await userMediaStore.set.setPhotoUploadErrorMessage('¡Ay, caramba! your photo is too large (max=11MB).') - setUploading(false) - return - } - - await onload(await readFile(file), file.name) - } - - setUploading(false) - toast.info('Photos uploaded ✓') - } - - const { getRootProps, getInputProps, open } = useDropzone({ - onDrop, - multiple: true, // support many - // When I get back from climbing trips, I have a huge pile of photos - // also the queue is handled sequentially, with callbacks individually - // for each file uploads... so it interops nicely with existing function - maxFiles: 40, - accept: { 'image/*': [] }, - useFsAccessApi: false, - noClick: uploading - }) - - return { uploading, getInputProps, getRootProps, openFileDialog: open } -} diff --git a/src/js/hooks/usePhotoUploader.tsx b/src/js/hooks/usePhotoUploader.tsx new file mode 100644 index 000000000..1a3806587 --- /dev/null +++ b/src/js/hooks/usePhotoUploader.tsx @@ -0,0 +1,154 @@ +import { useRouter } from 'next/router' +import { useDropzone, DropzoneInputProps, FileRejection } from 'react-dropzone' +import { toast } from 'react-toastify' +import { useSession } from 'next-auth/react' + +import { uploadPhoto, deleteMediaFromStorage } from '../userApi/media' +import useMediaCmd from './useMediaCmd' +import { MediaFormat } from '../types' +import { useUserGalleryStore } from '../stores/useUserGalleryStore' + +interface PhotoUploaderReturnType { + getInputProps: (props?: T) => T + getRootProps: (props?: T) => T + openFileDialog: () => void +} + +async function readFile (file: File): Promise> { + return await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onabort = () => reject(new Error('file reading was aborted')) + reader.onerror = () => reject(new Error('file reading has failed')) + reader.onload = async (event) => resolve(event) + // Starts reading the contents of the specified Blob, once finished, + // the result attribute contains an ArrayBuffer representing the file's data. + reader.readAsArrayBuffer(file) + }) +} + +/** + * Hook providing logic for handling all things photo-upload. + * Essential logic for handling file data and uploading it to the provider + * is all encapsulated here, as well as some other api shorthand. + * { onUploaded }: UsePhotoUploaderProps + * */ +export default function usePhotoUploader (): PhotoUploaderReturnType { + const router = useRouter() + + const setUploading = useUserGalleryStore(store => store.setUploading) + const isUploading = useUserGalleryStore(store => store.uploading) + const { data: sessionData } = useSession({ required: true }) + const { addMediaObjectsCmd } = useMediaCmd() + + /** When a file is loaded by the browser (as in, loaded from the local filesystem, + * not loaded from a webserver) we can begin to upload the bytedata to the provider */ + const onload = async (event: ProgressEvent, file: File): Promise => { + if (event.target === null || event.target.result === null) return // guard this + + const userUuid = sessionData?.user.metadata.uuid + if (userUuid == null) { + // this shouldn't happen + throw new Error('Login required.') + } + + const imageData = event.target.result as ArrayBuffer + + const { width, height } = await getImageDimensions(imageData) + + const { name, type, size } = file + + try { + const url = await uploadPhoto(name, imageData) + + const res = await addMediaObjectsCmd([{ + userUuid, + mediaUrl: url, + format: mineTypeToEnum(type), + width, + height, + size + }]) + + // if upload is successful but we can't update the database, + // then delete the upload + if (res == null) { + await deleteMediaFromStorage(url) + } + } catch (e) { + toast.error('Uploading error. Please try again.') + console.error('Meida upload error.', e) + } + } + + const onDrop = async (files: File[], rejections: FileRejection[]): Promise => { + if (rejections.length > 0) { console.warn('Rejected files: ', rejections) } + + setUploading(true) + await Promise.allSettled(files.map(async file => { + if (file.size > 11534336) { + toast.warn('¡Ay, caramba! one of your photos is too cruxy (please reduce the size to 11MB or under)') + return true + } + const content = await readFile(file) + await onload(content, file) + return true + })) + + setUploading(false) + + let msg: string | JSX.Element + if (router.asPath.startsWith('/u')) { + msg = 'Uploading completed! 🎉' + } else { + msg = <>Uploading completed! 🎉  Go to Profile. + } + toast.success(msg) + } + + const { getRootProps, getInputProps, open } = useDropzone({ + onDrop, + multiple: true, // support many + // When I get back from climbing trips, I have a huge pile of photos + // also the queue is handled sequentially, with callbacks individually + // for each file uploads... so it interops nicely with existing function + maxFiles: 40, + accept: { 'image/*': [] }, + useFsAccessApi: false, + noClick: isUploading + }) + + return { getInputProps, getRootProps, openFileDialog: open } +} + +export const mineTypeToEnum = (mineType: string): MediaFormat => { + switch (mineType) { + case 'image/jpeg': return MediaFormat.jpg + case 'image/png': return MediaFormat.png + case 'image/webp': return MediaFormat.webp + case 'image/avif': return MediaFormat.avif + } + throw new Error('Unsupported media type' + mineType) +} + +interface Dimensions { + width: number + height: number +} + +/** + * Get image width x height from image upload data. + * https://stackoverflow.com/questions/46399223/async-await-in-image-loading + */ +const getImageDimensions = async (imageData: ArrayBuffer): Promise => { + return await new Promise((resolve, reject) => { + const blob = new Blob([imageData], { type: 'image/jpeg' }) + + const image = new Image() + image.src = URL.createObjectURL(blob) + image.onload = () => resolve({ + height: image.naturalHeight, + width: image.naturalWidth + }) + image.onerror = reject + }) +} diff --git a/src/js/media/storageClient.ts b/src/js/media/storageClient.ts new file mode 100644 index 000000000..adddc1b68 --- /dev/null +++ b/src/js/media/storageClient.ts @@ -0,0 +1,40 @@ +import { Storage, GetSignedUrlConfig } from '@google-cloud/storage' + +/** + * GCloud storage client. Todo: move this to its own module. + */ +const storage = new Storage({ + credentials: { + type: 'service_account', + private_key: process.env.GC_BUCKET_PRIVATE_KEY ?? '', + client_email: process.env.GC_BUCKET_CLIENT_EMAIL ?? '' + } +}) + +/** + * Get a signed url for uploading to a bucket + * @param filename + */ +export const getSignedUrlForUpload = async (filename: string): Promise => { + const options: GetSignedUrlConfig = { + version: 'v4', + action: 'write', + expires: Date.now() + 15 * 60 * 1000 // 15 minutes + } + + // Get a signed URL for uploading the file + const [url] = await storage + .bucket(process.env.GC_BUCKET_NAME ?? '') + .file(filename) + .getSignedUrl(options) + + if (url == null) { + throw new Error('Unable get signed url for uploading') + } + + return url +} + +export const deleteMediaFromBucket = async (filename: string): Promise => { + await storage.bucket(process.env.GC_BUCKET_NAME ?? '').file(filename, { }).delete() +} diff --git a/src/js/sirv/SirvClient.ts b/src/js/sirv/SirvClient.ts deleted file mode 100644 index 91acaab65..000000000 --- a/src/js/sirv/SirvClient.ts +++ /dev/null @@ -1,344 +0,0 @@ -import axios from 'axios' -import { basename } from 'path' -import { v5 as uuidv5 } from 'uuid' -import AWS from 'aws-sdk' - -import { MediaType } from '../types' - -/** - * @deprecated - * Server-side configs - */ -export const SIRV_CONFIG = { - clientId: process.env.SIRV_CLIENT_ID_RO ?? null, - clientSecret: process.env.SIRV_CLIENT_SECRET_RO ?? null, - clientAdminId: process.env.SIRV_CLIENT_ID_RW ?? null, - clientAdminSecret: process.env.SIRV_CLIENT_SECRET_RW ?? null, - s3Key: process.env.S3_KEY ?? '', - s3Secret: process.env.S3_SECRET ?? '', - s3Bucket: process.env.S3_BUCKET ?? '' -} - -AWS.config.update({ - accessKeyId: SIRV_CONFIG.s3Key, - secretAccessKey: SIRV_CONFIG.s3Secret -}) - -export const s3Client = new AWS.S3({ - endpoint: new AWS.Endpoint('https://s3.sirv.com'), - s3ForcePathStyle: true -}) - -const client = axios.create({ - baseURL: 'https://api.sirv.com/v2', - headers: { - 'content-type': 'application/json' - } -}) - -const headers = { - 'content-type': 'application/json' -} - -interface TokenParamsType { - clientId: string | null - clientSecret: string | null -} - -const _validateTokenParams = ({ clientId, clientSecret }: TokenParamsType): boolean => - clientId != null && clientSecret != null - -export const getToken = async (isAdmin: boolean = false): Promise => { - const params: TokenParamsType = isAdmin - ? { - clientId: SIRV_CONFIG.clientAdminId, - clientSecret: SIRV_CONFIG.clientAdminSecret - } - : { - clientId: SIRV_CONFIG.clientId, - clientSecret: SIRV_CONFIG.clientSecret - } - - if (!_validateTokenParams(params)) { - console.log('Missing client token/secret') - return undefined - } - const res = await client.post( - '/token', - params) - - if (res.status === 200) { - return res.data.token - } - throw new Error('Sirv API.getToken() error' + res.statusText) -} - -export const getAdminToken = async (): Promise => await getToken(true) - -const getAdminTokenIfNotExist = async (token?: string): Promise => { - if (token != null) return token - - const _t = await getAdminToken() - - if (_t == null) { - throw new Error('Sirv API.getUserImages(): unable to get a token') - } - return _t -} - -const getTokenIfNotExist = async (token?: string): Promise => { - if (token != null) return token - - const _t = await getToken() - - if (_t == null) { - throw new Error('Sirv API.getUserImages(): unable to get a token') - } - return _t -} - -export interface UserImageReturnType { - mediaList: MediaType[] - mediaIdList: string[] -} -export const getUserImages = async (uuid: string, size: number = 200, token?: string): Promise => { - const _t = await getTokenIfNotExist(token) - const res = await client.post( - '/files/search', - { - query: `(extension:.jpg OR extension:.jpeg OR extension:.png) AND dirname:\\/u\\/${uuid} AND -dirname:\\/.Trash AND -filename:uid.json`, - sort: { - ctime: 'desc' - }, - size - }, - { - headers: { - ...headers, - Authorization: `bearer ${_t}` - } - } - ) - if (res.status === 200 && Array.isArray(res.data.hits)) { - const mediaIdList: string[] = [] - const mediaList = res.data.hits.map(entry => { - const { filename, ctime, mtime, contentType, meta } = entry._source - const mediaId = mediaUrlHash(filename) - mediaIdList.push(mediaId) - return ({ - ownerId: uuid, - filename, - mediaId, - ctime, - mtime, - contentType, - meta - }) - }) - - return { - mediaList, - mediaIdList - } - } - - throw new Error('Sirv API.getUserImages() error' + res.statusText) -} - -export const getImagesByFilenames = async (fileList: string[], token?: string): Promise <{ mediaList: MediaType[], idList: string[]}> => { - if (fileList.length === 0) { - return { - mediaList: [], - idList: [] - } - } - const _t = await getTokenIfNotExist(token) - - const _list = fileList.map(file => { - const name = basename(file)?.trim() - if (name == null) return null - return `filename:${name.replace(/\//g, '\\\\//')}` - }) - - const res = await client.post( - '/files/search', - { - query: - `(${_list.join(' OR ')}) AND -dirname:\\/.Trash`, - size: 50 - }, - { - headers: { - ...headers, - Authorization: `bearer ${_t}` - } - } - ) - - if (res.status === 200 && Array.isArray(res.data.hits)) { - const mediaIdList: string[] = [] - const mediaList = res.data.hits.map(entry => { - const { filename, ctime, mtime, contentType, meta } = entry._source - const mediaId = mediaUrlHash(filename) - mediaIdList.push(mediaId) - return ({ - ownerId: '', - filename, - mediaId, - ctime, - mtime, - contentType, - meta: stripMeta(meta) - }) - }) - - return { - mediaList: mediaList, - idList: mediaIdList - } - } - throw new Error('Sirv API.searchUserImage() error' + res.statusText) -} - -export const getFileInfo = async (uuid: string, filename: string, token?: string): Promise => { - const _t = await getTokenIfNotExist(token) - const res = await client.get( - '/files/stat?filename=' + encodeURIComponent(filename), - { - headers: { - ...headers, - Authorization: `bearer ${_t}` - } - } - ) - - if (res.status === 200) { - const { ctime, mtime, contentType, meta } = res.data - const mediaId = mediaUrlHash(filename) - return ({ - ownerId: uuid, - filename, - mediaId, - ctime, - mtime, - contentType, - meta - }) - } - throw new Error('Sirv API.getFileInfo() error' + res.statusText) -} - -export const getUserFiles = async (uuid: string, token?: string): Promise => { - const _t = await getTokenIfNotExist(token) - - const dir = encodeURIComponent(`/u/${uuid}`) - const res = await client.get( - '/files/readdir?dirname=' + dir, - { - headers: { - ...headers, - Authorization: `bearer ${_t}` - } - } - ) - - if (res.status === 200) { - console.log(res.data) - return null - } - - throw new Error('Sirv API.getUserFiles() error' + res.statusText) -} - -export const createUserDir = async (uuid: string, token?: string): Promise => { - const _t = await getAdminTokenIfNotExist() - try { - const res = await client.post( - `/files/mkdir?dirname=/u/${uuid}`, - {}, - { - headers: { - ...headers, - Authorization: `bearer ${_t}` - } - } - ) - - return res.status === 200 - } catch (e) { - console.log('Image API createUserDir() failed', e?.response?.status ?? '') - console.log(e) - return false - } -} - -/** - * Delete a photo from Sirv - * @param filename - * @param token - * @returns deleted photo filename - */ -export const remove = async (filename: string, token?: string): Promise => { - const _t = await getAdminTokenIfNotExist(token) - - const res = await client.post( - `/files/delete?filename=${filename}`, - null, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `bearer ${_t}` - } - } - ) - if (res.status >= 200 && res.status <= 204) { return filename } - throw new Error(`Image API delete() failed. Status: ${res.status}`) -} - -/** - * A hack to store current username in a json file under their media folder. - * This way given an image URL, we can load the json file to determine - * the username without calling Auth0 API. - * @param filename /u/{uuid}/uid.json file - * @param uid username to record - * @param token API RW token - * @returns true if successful - */ -export const addUserIdFile = async (filename: string, uid: string, token?: string): Promise => { - if (uid == null) return false - try { - const _t = await getAdminTokenIfNotExist(token) - const res = await client.post( - '/files/upload?filename=' + filename, - { - uid: uid.toLowerCase(), - ts: Date.now() - }, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `bearer ${_t}` - } - } - ) - if (res.status >= 200 && res.status <= 204) { - return true - } - return false - } catch (e) { - // Since this is not a super critical operation, - // we can swallow the exception - console.log('Image API create Uid file failed', e) - return false - } -} - -const stripMeta = ({ - width, - height, - format -}): any => ({ - width, height, format -}) - -export const mediaUrlHash = (mediaUrl: string): string => uuidv5(mediaUrl, uuidv5.URL) diff --git a/src/js/sirv/__mocks__/SirvClient.ts b/src/js/sirv/__mocks__/SirvClient.ts deleted file mode 100644 index cc44818f1..000000000 --- a/src/js/sirv/__mocks__/SirvClient.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { UserImageReturnType } from '../SirvClient' -const Client = jest.requireActual('../SirvClient') - -export const SIRV_CONFIG = Client.SIRV_CONFIG - -export const getUserImages = async (uuid: string, token?: string): Promise => ({ - mediaList: [ - { - ownerId: 'abe96612-2742-43b0-a128-6b19d4e4615f', - filename: '/u/abe96612-2742-43b0-a128-6b19d4e4615f/prussik-peak.jpeg', - mediaId: '5a6798c9-12b9-5c2c-aea2-793a1a3a22c0', - ctime: new Date('2022-05-16T16:27:51.008Z'), - mtime: new Date('2022-05-16T16:29:03.196Z'), - contentType: 'image/jpeg', - meta: { width: 1500, height: 1000, format: 'JPEG', duration: 0 } - }, - { - ownerId: 'abe96612-2742-43b0-a128-6b19d4e4615f', - filename: '/u/abe96612-2742-43b0-a128-6b19d4e4615f/annunaki.jpeg', - mediaId: 'acc88e88-6ad2-586f-8630-a6f69b6b79cd', - ctime: new Date('2022-05-16T16:27:50.860Z'), - mtime: new Date('2022-05-16T16:29:03.196Z'), - contentType: 'image/jpeg', - meta: { width: 1200, height: 800, format: 'JPEG', duration: 0 } - } - ], - mediaIdList: [ - '5a6798c9-12b9-5c2c-aea2-793a1a3a22c0', 'acc88e88-6ad2-586f-8630-a6f69b6b79cd'] -}) diff --git a/src/js/stores/useUserGalleryStore.ts b/src/js/stores/useUserGalleryStore.ts new file mode 100644 index 000000000..dd6e1f3c9 --- /dev/null +++ b/src/js/stores/useUserGalleryStore.ts @@ -0,0 +1,124 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +import { MediaConnection, MediaWithTags } from '../types' + +export interface UserGalleryState { + mediaConnection: MediaConnection + uploading: boolean +} + +export interface UserGalleryStore extends UserGalleryState { + setUploading: (uploadingState: boolean) => void + addToFront: (nextConnection: MediaConnection) => void + append: (nextConnection: MediaConnection) => void + updateOne: (media: MediaWithTags) => void + delete: (mediaId: string) => void + reset: (nextConnection: MediaConnection) => void +} + +const DEFAUL_STATES: UserGalleryState = { + mediaConnection: { + edges: [], + pageInfo: { + hasNextPage: false, + endCursor: '' + } + }, + uploading: false +} + +const OPTIONS = { + name: 'UserGallery', + enabled: process?.env?.NEXT_PUBLIC_DEVTOOLS_ENABLED === 'true' +} + +/** + * Data store for UserGallery. + */ +export const useUserGalleryStore = create()(devtools(set => ({ + ...DEFAUL_STATES, + + setUploading: (uploadingState) => set((state) => ({ + uploading: uploadingState + }), false, 'setUploading'), + + /** + * Add new media connection to the front of the list. + * Use this to add a newly upload new media. + * @param nextConnection + */ + addToFront: (nextConnection) => set((state) => ({ + mediaConnection: { + edges: nextConnection.edges.concat(state.mediaConnection.edges), + pageInfo: nextConnection.pageInfo + } + }), false, 'addToFront'), + + /** + * Append new media connection to the end of the list. + * Use this then when the client fetches more from the backend in response to a user scrolling down. + * @param nextConnection + */ + append: (nextConnection) => set((state) => ({ + mediaConnection: { + edges: state.mediaConnection.edges.concat(nextConnection.edges), + pageInfo: nextConnection.pageInfo + } + }), false, 'append'), + + updateOne: (media) => set((state) => { + const currentList = state.mediaConnection.edges + const foundIndex = currentList.findIndex(mediaEdge => mediaEdge.node.id === media.id) + + if (foundIndex < 0) { + return state + } + + currentList.splice(foundIndex, 1, { + node: media, + cursor: '' + }) + + return ({ + mediaConnection: { + edges: currentList, + pageInfo: state.mediaConnection.pageInfo + } + }) + }, false, 'updateOne'), + + /** + * Delete a media node by mediaId. + * @param mediaId + */ + delete: (mediaId) => set((state) => { + const currentList = state.mediaConnection.edges + const foundIndex = currentList.findIndex(mediaEdge => mediaEdge.node.id === mediaId) + + // not found, do nothing + if (foundIndex < 0) return state + + currentList.splice(foundIndex, 1) + + return ({ + mediaConnection: { + edges: currentList, + pageInfo: state.mediaConnection.pageInfo + } + }) + }, false, 'delete'), + + /** + * Reset the store. + */ + reset: (nextConnection) => set(() => ({ + mediaConnection: { + edges: nextConnection.edges, + pageInfo: nextConnection.pageInfo + } + }), false, 'reset') +} +), +OPTIONS +)) diff --git a/src/js/types.ts b/src/js/types.ts index 3de256d6e..dd3ac6aba 100644 --- a/src/js/types.ts +++ b/src/js/types.ts @@ -251,6 +251,14 @@ export interface EntityTag { climbName?: string areaName: string } + +export enum MediaFormat { + jpg = 'jpeg', + png = 'png', + webp = 'webp', + avif = 'avif', +} + /** * Media with climb & area tags */ @@ -260,7 +268,7 @@ export interface MediaWithTags { mediaUrl: string width: number height: number - format: string + format: MediaFormat size: number uploadTime: Date entityTags: EntityTag[] diff --git a/src/js/userApi/media.ts b/src/js/userApi/media.ts index ddfc24652..94ccf2b0d 100644 --- a/src/js/userApi/media.ts +++ b/src/js/userApi/media.ts @@ -28,7 +28,7 @@ export const uploadPhoto = async (filename: string, rawData: ArrayBuffer): Promi throw new Error('Missing upload data') } -export const removePhoto = async (filename: string): Promise => { +export const deleteMediaFromStorage = async (filename: string): Promise => { const res = await client.post( '/api/media/remove?filename=' + encodeURIComponent(filename), { @@ -38,7 +38,7 @@ export const removePhoto = async (filename: string): Promise => { } ) if (res.status === 200) { - return res.data + return } - throw new Error('Delete failed') + throw new Error('Local delete media api failed') } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 6dd093675..1a394436b 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -12,6 +12,8 @@ import '../styles/global.css' import '../../public/fonts/fonts.css' import useResponsive from '../js/hooks/useResponsive' import useUsernameCheck from '../js/hooks/useUsernameCheck' +import { useUserGalleryStore } from '../js/stores/useUserGalleryStore' +import { BlockingAlert } from '../components/ui/micro/AlertDialogue' Router.events.on('routeChangeStart', () => NProgress.start()) Router.events.on('routeChangeComplete', () => NProgress.done()) @@ -24,6 +26,7 @@ interface AppPropsWithAuth extends AppProps<{ session: any }> { export default function MyApp ({ Component, pageProps: { session, ...pageProps } }: AppPropsWithAuth): JSX.Element { const { isMobile } = useResponsive() + const uploading = useUserGalleryStore(store => store.uploading) return ( <> @@ -55,6 +58,11 @@ export default function MyApp ({ Component, pageProps: { session, ...pageProps } pauseOnHover theme='light' /> + {uploading && + } + />} {/* main call-to-action popup */} {/* */} diff --git a/src/pages/api/media/get-signed-url.ts b/src/pages/api/media/get-signed-url.ts index 78b91c611..daab26321 100644 --- a/src/pages/api/media/get-signed-url.ts +++ b/src/pages/api/media/get-signed-url.ts @@ -3,18 +3,21 @@ import { customAlphabet } from 'nanoid' import { nolookalikesSafe } from 'nanoid-dictionary' import { extname } from 'path' import { getServerSession } from 'next-auth' - import withAuth from '../withAuth' -import { s3Client, SIRV_CONFIG } from '../../../js/sirv/SirvClient' import { authOptions } from '../auth/[...nextauth]' +import { getSignedUrlForUpload } from '../../../js/media/storageClient' export interface MediaPreSignedProps { url: string fullFilename: string } + /** - * Generate a pre-signed url for uploading photos to Sirv - * @returns + * Local API getting a signed url for uploading media to Google storage. + * + * Usage: `/api/media/get-signed-url?filename=image001.jpg` + * + * See https://cloud.google.com/storage/docs/access-control/signed-urls */ const handler: NextApiHandler = async (req, res) => { if (req.method === 'GET') { @@ -31,18 +34,14 @@ const handler: NextApiHandler = async (req, res) => { throw new Error('Missing user metadata') } const { uuid } = session.user.metadata - const fullFilename = `/u/${uuid}/${safeFilename(filename)}` + /** + * Important: no starting / when working with buckets + */ + const fullFilename = `u/${uuid}/${safeFilename(filename)}` - const params = { - Bucket: SIRV_CONFIG.s3Bucket, Key: fullFilename, Expires: 60 - } + const url = await getSignedUrlForUpload(fullFilename) - const url = s3Client.getSignedUrl('putObject', params) - if (url != null) { - return res.status(200).json({ url, fullFilename }) - } else { - throw new Error('Error generating upload url') - } + return res.status(200).json({ url, fullFilename: '/' + fullFilename }) } catch (e) { console.log('Uploading to media server failed', e) return res.status(500).end() diff --git a/src/pages/api/media/remove.ts b/src/pages/api/media/remove.ts index a72cc938f..b980786a0 100644 --- a/src/pages/api/media/remove.ts +++ b/src/pages/api/media/remove.ts @@ -1,7 +1,8 @@ import { NextApiHandler } from 'next' -import { remove } from '../../../js/sirv/SirvClient' import withAuth from '../withAuth' +import { deleteMediaFromBucket } from '../../../js/media/storageClient' + // We need to disable the default body parser export const config = { api: { @@ -22,10 +23,14 @@ const handler: NextApiHandler = async (req, res) => { throw new Error('Expect only 1 filename param') } - const photoUrl = await remove(filename) - return res.status(200).send(photoUrl) + let filenameWithoutSlash = filename + if (filename.startsWith('/')) { + filenameWithoutSlash = filename.substring(1, filename.length) + } + await deleteMediaFromBucket(filenameWithoutSlash) + return res.status(200).end() } catch (e) { - console.log('#Removing image from media server failed', e) + console.log('Removing file from media server failed', e) return res.status(500).end() } } diff --git a/src/pages/api/user/profile.ts b/src/pages/api/user/profile.ts index 797d37153..8b956996f 100644 --- a/src/pages/api/user/profile.ts +++ b/src/pages/api/user/profile.ts @@ -2,7 +2,6 @@ import { NextApiHandler } from 'next' import withAuth from '../withAuth' import createMetadataClient, { Auth0UserMetadata } from './metadataClient' -import { addUserIdFile } from '../../../js/sirv/SirvClient' import { checkUsername, checkWebsiteUrl } from '../../../js/utils' type Handler = NextApiHandler @@ -51,8 +50,6 @@ const updateMyProfile: Handler = async (req, res) => { req.body.nick = (req.body.nick as string).toLowerCase() req.body.website = website - await addUserIdFile(`/u/${req.body.uuid as string}/uid.json`, req.body?.nick) - const metadata = await metadataClient.updateUserMetadata(req.body) res.json(metadata) } catch (err) { diff --git a/yarn.lock b/yarn.lock index a5baf5561..5e02b186b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1278,10 +1278,10 @@ resolved "https://registry.yarnpkg.com/@google-cloud/promisify/-/promisify-3.0.1.tgz#8d724fb280f47d1ff99953aee0c1669b25238c2e" integrity sha512-z1CjRjtQyBOYL+5Qr9DdYIfrdLBe746jRTYfaYU6MeXkqp7UfYs/jX16lFFVzZ7PGEJvqZNqYUEtb1mvDww4pA== -"@google-cloud/storage@^6.9.5": - version "6.9.5" - resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-6.9.5.tgz#2df7e753b90dba22c7926ecbe16affbd7489939d" - integrity sha512-fcLsDA8YKcGuqvhk0XTjJGVpG9dzs5Em8IcUjSjspYvERuHYqMy9CMChWapSjv3Lyw//exa3mv4nUxPlV93BnA== +"@google-cloud/storage@^6.11.0": + version "6.11.0" + resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-6.11.0.tgz#7217dd06184e609d1c444b7c930d76c6cffeb634" + integrity sha512-p5VX5K2zLTrMXlKdS1CiQNkKpygyn7CBFm5ZvfhVj6+7QUsjWvYx9YDMkYXdarZ6JDt4cxiu451y9QUIH82ZTw== dependencies: "@google-cloud/paginator" "^3.0.7" "@google-cloud/projectify" "^3.0.0" @@ -8762,7 +8762,9 @@ zen-observable@0.8.15: resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== -zustand@^3.7.1: - version "3.7.2" - resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.7.2.tgz#7b44c4f4a5bfd7a8296a3957b13e1c346f42514d" - integrity sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA== +zustand@^4.3.9: + version "4.3.9" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.3.9.tgz#a7d4332bbd75dfd25c6848180b3df1407217f2ad" + integrity sha512-Tat5r8jOMG1Vcsj8uldMyqYKC5IZvQif8zetmLHs9WoZlntTHmIoNM8TpLRY31ExncuUvUOXehd0kvahkuHjDw== + dependencies: + use-sync-external-store "1.2.0"