갤러리에서 사진이 보이도록 구현을 한 후 , 사용자가 사진의 위치를 변경하고 사진을 빼고 다시 추가하고 싶을 수 있다고 생각했다.
그래서 사진을 위치를 변경할 때 드래그로 가능하게 하면 좋을 것 같다고 생각해 React-DnD로 이미지를 업로드 하기 위해 이미지를 받고 을 사용하여 구현하려고 한다.
pnpm add react-dropzone @supabase/supabase-js
사용자가 Input에서 이미지를 추가하고 추가한 이미지가 뒤의 Preveiw 에서 보이고 preview화면에서 개별적으로 사진을 삭제할 수 있는 x 창과 순서를 변경할 수 있도록 하여야 한다. 여기서 변경된 정보를 다시 supabase에 저장하여야 한다.
주의 해야할 점은 우리는 순서가 넘어가는 버튼과 맨마지막 제출때에만 supabase table에 정보가 저장될 수 있도록 하였기 때문에 사진들이 supabase storage에 올라가고 public Url값을 setValue()로 저장만 해두고 supabase table에는 올라가지 않았다는 것이다.
먼저 삭제로직이 가능하도록 구현
제출버튼을 누르지는 않았지만 앞서 supabase storage에 이미지를 업로드하고 publicUrl을 받아와 제출시 table에 publicUrl이 올라가는 로직이기 때문에 스토리지에 올라가있는 url을 삭제할 수 있어햐했다.
supabase storage 이미지 삭제 로직
export const deleteGalleryImageFromSupabase = async (url: string) => {
const getPathFromUrl = (url: string) => {
const match = url.match(/\/invitation\/(.*)$/);
return match ? match[1] : '';
};
const path = getPathFromUrl(url);
if (!path) {
console.error('유효하지 않은 URL입니다.', url);
throw new Error('유효하지 않은 URL입니다.');
}
const { error } = await supabase.storage.from('invitation').remove([path]);
if (error) {
console.error('이미지 삭제에 실패했습니다.', error);
throw new Error('이미지 삭제 오류');
}
};
supabase에 앞서 저장되어 있는 이미지 url은 https:// 경로를 제외한 url이기 때문에 이미지 url을 받아와서 필요한 부분만 남기고 잘라 등록되어있는 url과 삭제하려고 하는 url 경로를 일치하도록 맞춰주었다.
return (
<div className={`${gridClass} p-2`}>
{gallery && gallery.images.length > 0 ? (
gallery.images.map((image, i) => (
<div
key={image}
className={`relative ${ratio === 'rectangle' ? 'aspect-[9/14]' : 'aspect-square'}`}
>
<Image
src={image}
alt={`galleryImage${i}`}
className={imgStyleClass}
layout='fill'
objectFit='cover'
/>
<IoClose
className='cursor-pointer text-white absolute right-2 top-2'
size={30}
onClick={() => handleDeleteImage(image)}
/>
</div>
))
) : (
<div>업로드 된 사진이 없습니다.</div>
)}
</div>
);
};
custom Hook으로 useMutation을 활용해 삭제할 수 있도록 작성을 해주었다. 그 이전 페이지를 preview와 input 부분을 나누어서 useFormContext를 사용할 수 있도록 Formprovider를 input부분만 넣어주었었는데 preview부분에서도 form 변경로직이 생겼기 때문에 preview까지 전체를 다 감싸줄 수 있도록 로직을 변경해주었다.
import { deleteGalleryImageFromSupabase } from '@/utils/uploadImg';
import { useMutation } from '@tanstack/react-query';
import { useFormContext } from 'react-hook-form';
export const useDeleteGalleryImage = () => {
const { setValue, getValues } = useFormContext();
return useMutation({
mutationFn: (imageUrl: string) => {
deleteGalleryImageFromSupabase(imageUrl);
},
onSuccess: (imageUrl: string) => {
const existingImages = getValues('gallery.images') || [];
const updatedImages = existingImages.filter((imgUrl: string) => imgUrl !== imageUrl);
setValue('gallery.images', updatedImages);
},
onError: (error) => console.error('갤러리 이미지 삭제 중 오류 발생', error),
});
};
이렇게 작성해주면 삭제가 잘 될 줄 알았는데 오류는 발생하지 않고 삭제가 되지 않았다. 어디에서 값이 들어오지 않는지 확인하기 위하여 하나하나 console.log를 찍어봤더니 mutationFn 에 supabase삭제할 url까지는 들어가고 onSuccess에서 url이 들어오고 있지 않았다.
원래라면 supabase 에서 삭제하고 따로 return을 하지 않아도 되지만 우리는 useWatch()를 사용하여 setValue값을 가져오고 있었기 때문에 저장되어 있는 value값을 처리해줘야 하기 때문에 onSuccess에서 url을 받아 뒤에 처리를 하기 위해서 url을 다시 return 해주어야 했다.
export const deleteGalleryImageFromSupabase = async (url: string) => {
const getPathFromUrl = (url: string) => {
const match = url.match(/\/invitation\/(.*)$/);
return match ? match[1] : '';
};
const path = getPathFromUrl(url);
if (!path) {
console.error('유효하지 않은 URL입니다.', url);
throw new Error('유효하지 않은 URL입니다.');
}
const { error } = await supabase.storage.from('invitation').remove([path]);
if (error) {
console.error('이미지 삭제에 실패했습니다.', error);
throw new Error('이미지 삭제 오류');
}
return url;
};
이렇게 했는데도 여전히 처리가 되지 않아 확인해보니 비동기 함수처리를 해주지 않아 값이 들어오지 않고 있었던 것이었다. customHook부분에서 mutationFn에 async await로 비동기 함수를 처리해주자 삭제가 잘 되었다.!
import { deleteGalleryImageFromSupabase } from '@/utils/uploadImg';
import { useMutation } from '@tanstack/react-query';
import { useFormContext } from 'react-hook-form';
export const useDeleteGalleryImage = () => {
const { setValue, getValues } = useFormContext();
return useMutation({
mutationFn: async (imageUrl: string) => {
return await deleteGalleryImageFromSupabase(imageUrl);
},
onSuccess: (imageUrl: string) => {
const existingImages = getValues('gallery.images') || [];
const updatedImages = existingImages.filter((imgUrl: string) => imgUrl !== imageUrl);
setValue('gallery.images', updatedImages);
},
onError: (error) => console.error('갤러리 이미지 삭제 중 오류 발생', error),
});
};
React DnD를 사용하기 위해서는 내가 사용하고자 하는 페이지에서 DndProvider를 설정해주어야 한다.
모바일 touch를 react drag and drop변형
multi-backend의 DndProvider를 사용하고 options에 HTML5toTouch를 넣으면
모바일에서 처리할 수 없는 drag 이벤트를 touch 이벤트로 바꿔줌!
react-dnd를 사용할 컨테이너를 DndProvider로 감싸줌
react-dnd react-dnd-multi-backend react-dnd-html5-backend react-dnd-touch-backend
import { InvitationFormType } from '@/types/invitationFormType.type';
import { Control, useWatch } from 'react-hook-form';
import WeddingGallery from '@/components/card/WeddingGallery';
import { DndProvider } from 'react-dnd-multi-backend';
import { HTML5toTouch } from '@/lib/reactDnd/dndBackends';
const GalleryPreview = ({ control }: { control: Control<InvitationFormType> }) => {
const gallery = useWatch({
control,
name: 'gallery',
});
return (
<DndProvider options={HTML5toTouch}>
<WeddingGallery gallery={gallery} />;
</DndProvider>
);
};
export default GalleryPreview;
import { useDeleteGalleryImage } from '@/hooks/queries/invitation/useUpdateImages';
import { InvitationFormType } from '@/types/invitationFormType.type';
import Image from 'next/image';
import { IoClose } from 'react-icons/io5';
import { useCallback } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { useFormContext } from 'react-hook-form';
type GalleryImageType = {
imageUrl: string;
index: number;
};
type GalleryPropType = Pick<InvitationFormType, 'gallery'>;
const GalleryImage = ({
image,
index,
moveImage,
handleDeleteImage,
ratio,
imgStyleClass,
}: {
image: string;
index: number;
moveImage: (dragIndex: number, hoverIndex: number) => void;
handleDeleteImage: (url: string) => void;
ratio: string;
imgStyleClass: string;
}) => {
const [{ isDragging }, drag] = useDrag({
type: 'image',
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [, drop] = useDrop({
accept: 'image',
hover: (item: GalleryImageType) => {
if (item.index !== index) {
moveImage(item.index, index);
item.index = index;
}
},
});
return (
<div
ref={(node) => {
if (node) {
drag(drop(node));
}
}}
className={`relative transition-all duration-900 ease-in-out ${ratio === 'rectangle' ? 'aspect-[9/14]' : 'aspect-square'} ${isDragging ? 'opacity-50' : ''}`}
style={{ transform: isDragging ? 'scale(1.05)' : 'scale(1)' }}
>
<Image
src={image}
alt={`galleryImage${index}`}
className={`${imgStyleClass} cursor-pointer`}
layout='fill'
objectFit='cover'
/>
<IoClose
className='cursor-pointer text-white absolute right-2 top-2 bg-gray-800/50'
size={15}
onClick={() => handleDeleteImage(image)}
/>
</div>
);
};
const WeddingGallery = ({ gallery }: GalleryPropType) => {
const { setValue } = useFormContext();
const images = gallery?.images || [];
const gridType = gallery?.grid;
const ratio = gallery?.ratio;
const imgStyleClass = ratio === 'rectangle' ? 'w-full h-[500px]' : 'w-full h-full';
const gridClass = gridType === 3 ? 'grid grid-cols-3 gap-2' : 'grid grid-cols-2 gap-2';
const deleteImage = useDeleteGalleryImage();
const handleDeleteImage = (imageUrl: string) => {
deleteImage.mutate(imageUrl);
setValue(
'gallery.images',
images.filter((img) => img !== imageUrl),
);
};
const moveImage = useCallback(
(dragIndex: number, hoverIndex: number) => {
const updatedImages = [...images];
const [removed] = updatedImages.splice(dragIndex, 1);
updatedImages.splice(hoverIndex, 0, removed);
setValue('gallery.images', updatedImages);
},
[images, setValue],
);
return (
<div className={`${gridClass} p-2`}>
{images.length > 0 ? (
images.map((image, index) => (
<GalleryImage
key={image}
image={image}
index={index}
handleDeleteImage={handleDeleteImage}
moveImage={moveImage}
ratio={ratio}
imgStyleClass={imgStyleClass}
/>
))
) : (
<div>업로드 된 사진이 없습니다.</div>
)}
</div>
);
};
export default WeddingGallery;