Skip to content

Commit

Permalink
Merge pull request #32 from tabi-memo/file-upload-ui
Browse files Browse the repository at this point in the history
Image upload in Trip Create/Edit pages
  • Loading branch information
samuraikun authored Feb 23, 2024
2 parents 3c48398 + e6d0e73 commit 2669948
Show file tree
Hide file tree
Showing 7 changed files with 117 additions and 105 deletions.
53 changes: 0 additions & 53 deletions app/trip/[id]/action/update-image-metadata.ts

This file was deleted.

46 changes: 3 additions & 43 deletions app/trip/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
'use client'

import { useState } from 'react'
import { Box, Container, useColorModeValue, Input } from '@chakra-ui/react'
import { createClient } from '@supabase/supabase-js'
import { Box, Container, useColorModeValue } from '@chakra-ui/react'
import { useRouter } from 'next/navigation'
import { PrimaryButton } from '@/components/button'
import { Loading } from '@/components/loading'
import { useTripDetailsGet } from '../hooks'
import { updateImageMetadataAction } from './action/update-image-metadata'
import { TripDetailsHeader, TripDetailsTabs } from './components'

export default function TripDetailsPage({
Expand All @@ -19,39 +16,12 @@ export default function TripDetailsPage({
const color = useColorModeValue('black', 'gray.300')

const router = useRouter()
const { tripDetailsData, tripDetailsLoading, tripDetailsRefetch } =
useTripDetailsGet(params.id)
const { tripDetailsData, tripDetailsLoading } = useTripDetailsGet(params.id)

if (!tripDetailsData && !tripDetailsLoading)
throw new Error('No trip data found')

const tripData = tripDetailsData?.tripsCollection
const [selectedImage, setSelectedImage] = useState<File | null>(null)

const uploadImage = async (id: string, file: File) => {
try {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_API_KEY!
)

// TODO: Authenticated user can upload images with policy
const { data: uploadData, error: uploadError } = await supabase.storage
.from('tabi-memo-uploads')
.upload(`trips/${id}/${file.name}`, file, { upsert: true })

if (uploadError) throw new Error(uploadError.message)

// NOTE: Server action doen't return result in Client Component. I don't know why.
await updateImageMetadataAction(id, uploadData.path)

setSelectedImage(file)
await tripDetailsRefetch()
} catch (error) {
console.error({ error })
throw error
}
}

return (
<Box as="main" minH="100svh" bg={bg} color={color}>
Expand All @@ -66,11 +36,7 @@ export default function TripDetailsPage({
<>
<TripDetailsHeader
id={tripData.edges[0].node.id}
image={
selectedImage
? URL.createObjectURL(selectedImage)
: tripData.edges[0].node.image_url
}
image={tripData.edges[0].node.image_url}
title={tripData.edges[0].node.title}
dateFrom={tripData.edges[0].node.date_from}
dateTo={tripData.edges[0].node.date_to}
Expand Down Expand Up @@ -114,12 +80,6 @@ export default function TripDetailsPage({
>
Add Activity
</PrimaryButton>
<Input
type="file"
id="imageUpload"
accept="image/*"
onChange={(e) => uploadImage(params.id, e.target.files![0])}
/>
</Box>
</>
)}
Expand Down
51 changes: 45 additions & 6 deletions app/trip/components/trip-form.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import {
FormControl,
Expand All @@ -11,7 +12,8 @@ import {
Flex,
Checkbox,
Box,
useDisclosure
useDisclosure,
Input
} from '@chakra-ui/react'
import { PrimaryButton, SecondaryButton } from '@/components/button'
import { CustomDatePicker } from '@/components/date'
Expand Down Expand Up @@ -57,6 +59,7 @@ export const TripForm = ({ tripDetails, tags, tripTags }: TripFormProps) => {
'/images/no_image_light.jpg',
'/images/no_image_dark.jpg'
)
const [selectedImage, setSelectedImage] = useState<File | null>(null)
const { isOpen, onOpen, onClose } = useDisclosure()

const { createTrip, isTripCreating } = useTripCreate()
Expand All @@ -81,6 +84,7 @@ export const TripForm = ({ tripDetails, tags, tripTags }: TripFormProps) => {
: undefined,
date_to: tripDetails?.dateTo ? getDateObj(tripDetails.dateTo) : null,
image_url: tripDetails?.image || null,
uploaded_image_file: null,
selectedTags: tripTags ? tripTags.data.map((tag) => tag.tag_id) : [],
cost: tripDetails?.cost ? tripDetails.cost.toString() : null,
cost_unit: tripDetails?.costUnit
Expand Down Expand Up @@ -143,14 +147,49 @@ export const TripForm = ({ tripDetails, tags, tripTags }: TripFormProps) => {
<FormErrorMessage>{errors?.date_to?.message}</FormErrorMessage>
</FormControl>

{/* TODO Image Upload to storage & Send the URL string to DB */}
<FormControl isInvalid={!!errors.image_url}>
<FormControl isInvalid={!!errors.uploaded_image_file}>
<FormLabel>Image</FormLabel>
<HStack gap={{ base: '20px', md: '34px' }}>
<Image alt="" src={tripDetails?.image || imageSrc} width="50%" />
<PrimaryButton variant="outline">Select Image </PrimaryButton>
{selectedImage ? (
<Image
alt="Selected Image"
src={URL.createObjectURL(selectedImage)}
width="50%"
objectFit="cover"
/>
) : (
<Image
alt="Default Image"
src={tripDetails?.image || imageSrc}
width="50%"
objectFit="cover"
/>
)}
<Controller
name="uploaded_image_file"
control={control}
render={({ field: { onChange } }) => (
<PrimaryButton variant="outline" as="label" cursor="pointer">
Select Image
<Input
type="file"
accept="image/*"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
setSelectedImage(file)
onChange(file)
}
}}
hidden
/>
</PrimaryButton>
)}
/>
</HStack>
<FormErrorMessage>{errors?.image_url?.message}</FormErrorMessage>
<FormErrorMessage>
{errors?.uploaded_image_file?.message}
</FormErrorMessage>
</FormControl>

<FormControl isInvalid={!!errors?.selectedTags}>
Expand Down
6 changes: 6 additions & 0 deletions app/trip/hooks/useTripCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'
import { formatToISODate } from '@/libs/utils'
import { useUserId } from '@/providers/session-provider'
import { TripSchema } from '../schema'
import { useUploadFile } from './useUploadFile'
import { useTripsGet } from '.'
import { useCreateTripMutation, useCreateTripTagMutation } from '@generated/api'

Expand All @@ -17,6 +18,7 @@ export const useTripCreate = () => {
useCreateTripTagMutation()

const { tripsRefetch } = useTripsGet()
const { uploadFile } = useUploadFile()

const createTrip = async (data: TripSchema) => {
try {
Expand Down Expand Up @@ -50,6 +52,10 @@ export const useTripCreate = () => {

await Promise.all([...createPromises])

if (data.uploaded_image_file && createdTripId) {
await uploadFile(data.uploaded_image_file, createdTripId)
}

tripsRefetch()
router.push('/')

Expand Down
17 changes: 14 additions & 3 deletions app/trip/hooks/useTripUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'
import { formatToISODate } from '@/libs/utils'
import { TripDetailsArgs, TripTagsArgs } from '../components/trip-form'
import { TripSchema } from '../schema'
import { useUploadFile } from './useUploadFile'
import { useTripsGet } from '.'
import {
useUpdateTripMutation,
Expand All @@ -25,13 +26,14 @@ export const useTripUpdate = (
useDeleteTripTagMutation()

const { tripsRefetch } = useTripsGet()
const { uploadFile, isMetadataUpdating } = useUploadFile()

const updateTrip = async (data: TripSchema) => {
if (!tripDetails) throw new Error('Trip details is not found')

try {
// Trip Update
await updateTripMutation({
const res = await updateTripMutation({
variables: {
id: tripDetails.id,
set: {
Expand All @@ -45,6 +47,10 @@ export const useTripUpdate = (
}
})

const updatedTripId = res.data?.updatetripsCollection?.records[0]?.id

if (!updatedTripId) throw new Error('Failed to update a trip')

// TripTags Update
const selectedTags = data.selectedTags
const tripTagsArray = tripTags?.data || []
Expand All @@ -71,10 +77,14 @@ export const useTripUpdate = (

await Promise.all([...deletePromises, ...createPromises])

if (data.uploaded_image_file && updatedTripId) {
await uploadFile(data.uploaded_image_file, updatedTripId)
}

tripDetails.refetch()
tripTags?.refetch()
tripsRefetch()
router.push(`/trip/${tripDetails.id}`)
router.push(`/trip/${updatedTripId}`)

toast({
title: 'Successfully updated!',
Expand Down Expand Up @@ -104,6 +114,7 @@ export const useTripUpdate = (
return {
updateTrip,
isTripUpdating,
isTripTagsUpdating: isTripTagCreating || isTripTagDeleting
isTripTagsUpdating:
isTripTagCreating || isTripTagDeleting || isMetadataUpdating
}
}
40 changes: 40 additions & 0 deletions app/trip/hooks/useUploadFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createClient } from '@supabase/supabase-js'
import { useUpdateTripMutation } from '@generated/api'

export const useUploadFile = () => {
const [updateTripMutation, { loading: isMetadataUpdating }] =
useUpdateTripMutation()

// Upload image to Supabase Storage & Update image_url by server action
const uploadFile = async (file: File, tripId: string) => {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_API_KEY!
)

// TODO: Authenticated user can upload images with policy
const { data: uploadData, error: uploadError } = await supabase.storage
.from('tabi-memo-uploads')
.upload(`trips/${tripId}/${file.name}`, file, { upsert: true })

if (uploadError) throw new Error(uploadError.message)

const {
data: { publicUrl }
} = supabase.storage
.from(process.env.NEXT_PUBLIC_BUCKET_NAME!)
.getPublicUrl(uploadData.path)

// Update image_url by mutation
await updateTripMutation({
variables: {
id: tripId,
set: {
image_url: publicUrl
}
}
})
}

return { uploadFile, isMetadataUpdating }
}
9 changes: 9 additions & 0 deletions app/trip/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ const tripSchema = z.object({
}),
date_to: z.date().nullable(),
image_url: z.string().nullable(),
uploaded_image_file: z
.instanceof(File)
.nullable()
.refine((file) => file === null || file.size <= 10_000_000, {
message: 'File size must be less than 10MB'
})
.refine((file) => file === null || file.type.startsWith('image/'), {
message: 'Only image files are allowed'
}),
selectedTags: z.array(z.string()),
cost: z.string().nullable(),
cost_unit: z.string().nullable()
Expand Down

0 comments on commit 2669948

Please sign in to comment.