Skip to content

Commit

Permalink
Merge pull request #21 from SkyLightQP/develop
Browse files Browse the repository at this point in the history
feat: implement upload the image in AdminPage
  • Loading branch information
SkyLightQP authored Sep 12, 2024
2 parents b8f75f2 + 0af598c commit df9a707
Show file tree
Hide file tree
Showing 11 changed files with 455 additions and 30 deletions.
8 changes: 8 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,13 @@ module.exports = {
reactStrictMode: process.env.NODE_ENV === 'production',
eslint: {
ignoreDuringBuilds: true
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'tfkvzrwajkrxpnfcpvhx.supabase.co'
}
]
}
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^18.2.0",
"react-gesture-responder": "^2.1.0",
"react-grid-dnd": "^2.1.2",
"react-hook-form": "^7.33.1",
"react-hotkeys-hook": "^3.4.6",
"react-markdown": "^8.0.5"
Expand Down
35 changes: 26 additions & 9 deletions src/components/ContentView/ImageView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React, { FC } from 'react';
import React, { FC, useState } from 'react';
import styled from '@emotion/styled';
import Image from 'next/image';
import { useDisclosure } from '@chakra-ui/react';
import Breakpoint from '../../../styles/Breakpoint';
import ImageDetailModal from '../../Dialogs/ImageDetailModal';

interface ImageViewProps {
readonly urls: string[];
readonly images: { url: string; alt: string }[];
}

const ImageGroup = styled.div`
Expand All @@ -27,15 +29,30 @@ const ImageGroup = styled.div`
const StyledImage = styled(Image)`
width: auto;
height: 80px;
border-radius: 10px;
border-radius: 6px;
cursor: pointer;
`;

export const ImageView: FC<ImageViewProps> = ({ urls }) => {
export const ImageView: FC<ImageViewProps> = ({ images }) => {
const modal = useDisclosure();
const [modalContext, setModalContext] = useState({
url: '',
alt: ''
});

const onImageClick = (url: string, alt: string) => {
setModalContext({ url, alt });
modal.onOpen();
};

return (
<ImageGroup>
{urls.map((src) => (
<StyledImage key={src} src={src} alt="프로젝트 썸네일" width="147" height="80" />
))}
</ImageGroup>
<>
<ImageGroup>
{images.map(({ url, alt }) => (
<StyledImage key={url} src={url} alt={alt} width="147" height="80" onClick={() => onImageClick(url, alt)} />
))}
</ImageGroup>
<ImageDetailModal modalController={modal} image={modalContext} />
</>
);
};
36 changes: 36 additions & 0 deletions src/components/Dialogs/ImageDetailModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay, useDisclosure } from '@chakra-ui/react';
import Image from 'next/image';
import styled from '@emotion/styled';
import Colors from '../../styles/Colors';

interface ImageDetailModalProps {
readonly modalController: ReturnType<typeof useDisclosure>;
readonly image: { url: string; alt: string };
}

const ImageLabel = styled.p`
font-size: 14px;
text-align: center;
padding: 8px 0;
color: ${Colors.GRAY_DARKEN};
`;

const ImageDetailModal: React.FC<ImageDetailModalProps> = ({ modalController, image }) => {
const { isOpen, onClose } = modalController;

return (
<Modal isOpen={isOpen} onClose={onClose} isCentered size="4xl">
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody>
<Image src={image.url} alt={image.alt} width={1024} height={768} />
<ImageLabel>{image.alt}</ImageLabel>
</ModalBody>
</ModalContent>
</Modal>
);
};

export default ImageDetailModal;
227 changes: 227 additions & 0 deletions src/components/Dialogs/ImageModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Divider,
IconButton,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
useDisclosure,
useToast
} from '@chakra-ui/react';
import { SubmitHandler, useForm } from 'react-hook-form';
import styled from '@emotion/styled';

import { RiArrowRightLine } from '@remixicon/react';
import { GridContextProvider, GridDropZone, GridItem, swap } from 'react-grid-dnd';
import Image from 'next/image';
import { SchemaType } from '../../types/type-util';
import { useSupabase } from '../../utils/supabase';
import { Space } from '../Space';
import Colors from '../../styles/Colors';

const InputContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
grid-gap: 10px;
background-color: white;
`;

const StyledFileInput = styled(Input)`
padding-top: 5px;
`;

const DropzoneContainer = styled.div`
display: flex;
touch-action: none;
width: 100%;
height: 100%;
`;

const StyledDropzone = styled(GridDropZone)`
flex: 1;
height: 300px;
`;

const StyledImage = styled(Image)`
width: auto;
height: 90px;
object-fit: cover;
user-select: none;
cursor: pointer;
border: 1px solid ${Colors.GRAY};
margin-right: 10px;
&:hover {
outline: 1px solid ${Colors.PRIMARY};
}
`;

const HintText = styled.p`
font-size: 14px;
color: ${Colors.GRAY_DARKEN};
margin: 4px 0;
`;

interface AddForm {
readonly id: number;
readonly file: File[];
readonly alt: string;
}

interface LinkModalProps {
readonly modalController: ReturnType<typeof useDisclosure>;
readonly dataId: number;
}

const ImageModal: React.FC<LinkModalProps> = ({ modalController, dataId }) => {
const [data, setData] = useState<SchemaType<'images'>[]>([]);
const { isOpen, onClose } = modalController;
const { register, handleSubmit, reset } = useForm<AddForm>();
const supabase = useSupabase();
const toast = useToast({
isClosable: true,
position: 'top-left'
});

const fetchData = async (id: number) => {
if (id > 0) {
const { data: contents, error } = await supabase.from('contents').select('*, images(*)').match({ id });
if (error !== null) {
toast({
title: 'Database Error',
description: error?.message ?? 'Unknown Error',
status: 'error'
});
return;
}
if (contents !== null) {
const content = contents[0];
setData(content.images.sort((a, b) => a.order - b.order));
}
}
};

const onChangeData = (sourceId: string, sourceIndex: number, targetIndex: number) => {
const nextState = swap(data, sourceIndex, targetIndex);
setData(nextState);
};

const onAddClick: SubmitHandler<AddForm> = async ({ file, alt }) => {
const filePath = `${+new Date()}-${file[0].name}`;
const { error } = await supabase.storage.from('images').upload(filePath, file[0]);
if (error !== null) {
toast({
title: 'Storage Error',
description: error?.message ?? 'Unknown Error',
status: 'error'
});
return;
}
const urlResult = supabase.storage.from('images').getPublicUrl(filePath);

const nextOrder = data ? data.length + 1 : 1;
await supabase.from('images').insert({
image_url: urlResult.data.publicUrl,
alt,
order: nextOrder,
contentId: dataId
});
await fetchData(dataId);
reset({ file: [], alt: '' });
};

const onDeleteClick = async (id: number) => {
const { data: rawImages } = await supabase.from('images').select('*').match({ id });
if (rawImages === null) return;
const image = rawImages[0];
const imageUrl = image.image_url.split('/images/')[1];
await supabase.from('images').delete().match({ id });
await supabase.storage.from('images').remove([imageUrl]);
await fetchData(dataId);
};

const onApplyClick = async () => {
if (data.length > 0) {
const promisedOrder = data.map(async ({ id }, index) => {
await supabase
.from('images')
.update({ order: index + 1 })
.match({ id });
});
await Promise.all(promisedOrder);
}
onClose();
};

useEffect(() => {
fetchData(dataId).then();
}, [dataId]);

return (
<Modal isOpen={isOpen} onClose={onClose} isCentered size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>컨텐츠 이미지</ModalHeader>
<ModalCloseButton />
<ModalBody>
<InputContainer>
<StyledFileInput type="file" {...register('file', { required: true })} />
<Input type="text" placeholder="대체 텍스트" width={220} {...register('alt', { required: true })} />
<IconButton
colorScheme="blue"
aria-label="이미지 추가"
icon={<RiArrowRightLine size={20} />}
onClick={handleSubmit(onAddClick)}
/>
</InputContainer>
<HintText>* 이미지를 우클릭하면 해당 이미지를 영구 삭제합니다.</HintText>
<Space y={10} />
<Divider />
<Space y={10} />
<GridContextProvider onChange={onChangeData}>
<DropzoneContainer>
<StyledDropzone id="images" boxesPerRow={3} rowHeight={100} disableDrag={false} disableDrop={false}>
{data.map((image) => (
<GridItem
key={image.id}
onContextMenu={(e) => {
e.preventDefault();
onDeleteClick(image.id);
}}
>
<StyledImage
src={image.image_url}
alt={image.alt}
width={200}
height={90}
onDragStart={(e) => e.preventDefault()}
/>
</GridItem>
))}
</StyledDropzone>
</DropzoneContainer>
</GridContextProvider>
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" mr={3} fontWeight="normal" onClick={onApplyClick}>
적용
</Button>
<Button fontWeight="normal" onClick={onClose}>
취소
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

export default ImageModal;
12 changes: 10 additions & 2 deletions src/components/Dialogs/UpdateModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({ modalController, fields, defa
});
isFirstOpen.current = true;
}
}, [isFirstOpen, setValue, fields, defaultValue]);
}, [isOpen, isFirstOpen, setValue, fields, defaultValue]);

return (
<Modal
Expand Down Expand Up @@ -77,7 +77,15 @@ const UpdateModal: React.FC<UpdateModalProps> = ({ modalController, fields, defa
</ModalBody>

<ModalFooter>
<Button colorScheme="blue" mr={3} fontWeight="normal" onClick={handleSubmit(onUpdateClick)}>
<Button
colorScheme="blue"
mr={3}
fontWeight="normal"
onClick={() => {
handleSubmit(onUpdateClick)();
isFirstOpen.current = false;
}}
>
수정
</Button>
<Button
Expand Down
Loading

0 comments on commit df9a707

Please sign in to comment.