From 68cae8973db11ba24b302087aa7100dd72606d8b Mon Sep 17 00:00:00 2001 From: Daegyeom Ha Date: Tue, 10 Sep 2024 20:06:05 +0900 Subject: [PATCH 01/12] feat: add `Image Setting` button in DraggableTable --- src/components/DraggableTable/index.tsx | 25 ++++++++++++++++++------- src/pages/admin/content.tsx | 1 + 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/components/DraggableTable/index.tsx b/src/components/DraggableTable/index.tsx index e7ee458..ad41f79 100644 --- a/src/components/DraggableTable/index.tsx +++ b/src/components/DraggableTable/index.tsx @@ -1,7 +1,7 @@ import React, { Fragment } from 'react'; import { ButtonGroup, IconButton, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react'; import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'; -import { RiDeleteBin2Line, RiEditLine, RiLink } from '@remixicon/react'; +import { RiDeleteBin2Line, RiEditLine, RiImageCircleFill, RiLink } from '@remixicon/react'; interface Column { readonly label: string; @@ -16,9 +16,10 @@ interface DraggableTableProps { readonly onDragEnd: (result: DropResult) => void; readonly onTableUpdateClick: (snapshot: T) => void; readonly onTableDeleteClick: (snapshot: T) => void; - /* eslint-disable react/require-default-props */ readonly useLinkControl?: boolean; + readonly useImageControl?: boolean; readonly onTableLinkClick?: (snapshot: T) => void; + readonly onTableImageClick?: (snapshot: T) => void; } const DraggableTable = ({ @@ -28,7 +29,9 @@ const DraggableTable = ({ onTableUpdateClick, onTableDeleteClick, useLinkControl, - onTableLinkClick + useImageControl, + onTableLinkClick, + onTableImageClick }: DraggableTableProps) => { return ( @@ -68,22 +71,30 @@ const DraggableTable = ({ })} + {useImageControl && ( + } + onClick={() => onTableImageClick?.(item)} + /> + )} {useLinkControl && ( } - onClick={() => onTableLinkClick && onTableLinkClick(item)} + onClick={() => onTableLinkClick?.(item)} /> )} } onClick={() => onTableUpdateClick(item)} /> } onClick={() => onTableDeleteClick(item)} diff --git a/src/pages/admin/content.tsx b/src/pages/admin/content.tsx index 5c536ec..184de7a 100644 --- a/src/pages/admin/content.tsx +++ b/src/pages/admin/content.tsx @@ -215,6 +215,7 @@ const AdminContent: React.FC = () => { Date: Tue, 10 Sep 2024 20:22:15 +0900 Subject: [PATCH 02/12] fix: update scheme --- src/types/schema.ts | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/types/schema.ts b/src/types/schema.ts index d79e79e..3acc1a9 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -9,7 +9,6 @@ export type Database = { description: string; hasMargin: boolean; id: number; - images: Json; isHidden: boolean; order: number; sectionId: number; @@ -23,7 +22,6 @@ export type Database = { description: string; hasMargin: boolean; id?: number; - images?: Json; isHidden: boolean; order: number; sectionId: number; @@ -37,7 +35,6 @@ export type Database = { description?: string; hasMargin?: boolean; id?: number; - images?: Json; isHidden?: boolean; order?: number; sectionId?: number; @@ -56,6 +53,44 @@ export type Database = { } ]; }; + images: { + Row: { + alt: string; + contentId: number; + createdAt: string; + id: number; + image_url: string; + order: number; + updatedAt: string; + }; + Insert: { + alt: string; + contentId: number; + createdAt?: string; + id?: number; + image_url: string; + order: number; + updatedAt?: string; + }; + Update: { + alt?: string; + contentId?: number; + createdAt?: string; + id?: number; + image_url?: string; + order?: number; + updatedAt?: string; + }; + Relationships: [ + { + foreignKeyName: 'images_contentId_fkey'; + columns: ['contentId']; + isOneToOne: false; + referencedRelation: 'contents'; + referencedColumns: ['id']; + } + ]; + }; links: { Row: { contentId: number; From 1c78c155033700b2dfa58d6d0d802a35359c2961 Mon Sep 17 00:00:00 2001 From: Daegyeom Ha Date: Wed, 11 Sep 2024 20:30:14 +0900 Subject: [PATCH 03/12] chore: add react-grid-dnd dependency --- package.json | 2 ++ yarn.lock | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 582dde0..5e452ff 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/yarn.lock b/yarn.lock index eca3e7a..6071e51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -85,6 +85,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.3.1": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.6.tgz#9afc3289f7184d8d7f98b099884c26317b9264d2" + integrity sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.7.2": version "7.12.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" @@ -1212,6 +1219,13 @@ dependencies: "@types/react" "*" +"@types/react-dom@^16.8.3": + version "16.9.24" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.24.tgz#4d193d7d011267fca842e8a10a2d738f92ec5c30" + integrity sha512-Gcmq2JTDheyWn/1eteqyzzWKSqDjYU6KYsIvH7thb7CR5OYInAWOX+7WnKf6PaU/cbdOc4szJItcDEJO7UGmfA== + dependencies: + "@types/react" "^16" + "@types/react-dom@^18.2.19": version "18.2.19" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.19.tgz#b84b7c30c635a6c26c6a6dfbb599b2da9788be58" @@ -1238,6 +1252,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^16", "@types/react@^16.8.10": + version "16.14.60" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.60.tgz#f7ab62a329b82826f12d02bc8031d4ef4b5e0d81" + integrity sha512-wIFmnczGsTcgwCBeIYOuy2mdXEiKZ5znU/jNOnMZPQyCcIxauMGWlX0TNG4lZ7NxRKj7YUIZRneJQSSdB2jKgg== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "^0.16" + csstype "^3.0.2" + "@types/react@~18.0.28": version "18.0.38" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.38.tgz#02a23bef8848b360a0d1dceef4432c15c21c600c" @@ -1252,6 +1275,11 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== +"@types/scheduler@^0.16": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" + integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== + "@types/semver@^7.5.0": version "7.5.8" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" @@ -3823,7 +3851,7 @@ prettier@^3.2.5: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368" integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A== -prop-types@^15.0.0, prop-types@^15.6.2, prop-types@^15.8.1: +prop-types@^15.0.0, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -3906,6 +3934,26 @@ react-focus-lock@^2.9.1: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" +react-gesture-responder@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-gesture-responder/-/react-gesture-responder-2.1.0.tgz#204606dc23e388d6d51d0f27d4acf7ede2bd10ba" + integrity sha512-uXfFNOtSus5zo2u2WoXMmfAWLdbAYvubmA4AOk9UyLO9n7koVagvSHW7MOUEnhE+F6NeVuyTc+8pyZxLKwyxmg== + dependencies: + "@types/react" "^16.8.10" + "@types/react-dom" "^16.8.3" + tslib "^1.9.3" + +react-grid-dnd@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/react-grid-dnd/-/react-grid-dnd-2.1.2.tgz#6e442eb97656b3823a93998a25123a5ed5bcbecc" + integrity sha512-E+XcyemjmRm5Dk3Rn4e2KPpRK4dF1e8NyuOXhm4ZsmVIJVnkP+AzojkTn333ec2Fbe55PUxuHqYA6Fl0Wz65Ng== + dependencies: + "@types/react" "^16.8.10" + "@types/react-dom" "^16.8.3" + react-spring "9.0.0-beta.8" + resize-observer-polyfill "^1.5.1" + tslib "^1.9.3" + react-hook-form@^7.33.1: version "7.33.1" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.33.1.tgz#8c4410e3420788d3b804d62cc4c142915c2e46d0" @@ -3985,6 +4033,14 @@ react-remove-scroll@^2.5.4: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" +react-spring@9.0.0-beta.8: + version "9.0.0-beta.8" + resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-9.0.0-beta.8.tgz#dd4348d3d6dd88ff596b36fa85b3328200914b15" + integrity sha512-pcyQqr5W9HBM5Rm0rmdbVIEQz7wocAfWBNWSUm/EEvpb4M0OQ3Xp9JW6Ayo+6ZgV7fLzJJHO/QgNdUMpAtVaTw== + dependencies: + "@babel/runtime" "^7.3.1" + prop-types "^15.5.8" + react-style-singleton@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" @@ -4069,6 +4125,11 @@ remark-rehype@^10.0.0: mdast-util-to-hast "^12.1.0" unified "^10.0.0" +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -4510,7 +4571,7 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^1.0.0: +tslib@^1.0.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== From d2df000cb14eb2c4383c0758718901a08cac8eed Mon Sep 17 00:00:00 2001 From: Daegyeom Ha Date: Wed, 11 Sep 2024 20:43:53 +0900 Subject: [PATCH 04/12] feat: implement ImageModal to upload images --- src/components/Dialogs/ImageModal.tsx | 220 ++++++++++++++++++++++++++ src/pages/admin/content.tsx | 7 + 2 files changed, 227 insertions(+) create mode 100644 src/components/Dialogs/ImageModal.tsx diff --git a/src/components/Dialogs/ImageModal.tsx b/src/components/Dialogs/ImageModal.tsx new file mode 100644 index 0000000..4d70193 --- /dev/null +++ b/src/components/Dialogs/ImageModal.tsx @@ -0,0 +1,220 @@ +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 { 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.img` + 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; + readonly dataId: number; +} + +const ImageModal: React.FC = ({ modalController, dataId }) => { + const [data, setData] = useState[]>([]); + const { isOpen, onClose } = modalController; + const { register, handleSubmit, reset } = useForm(); + 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 = 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 ( + + + + 컨텐츠 이미지 + + + + + + } + onClick={handleSubmit(onAddClick)} + /> + + * 이미지를 우클릭하면 해당 이미지를 영구 삭제합니다. + + + + + + + {data.map((image) => ( + { + e.preventDefault(); + onDeleteClick(image.id); + }} + > + e.preventDefault()} /> + + ))} + + + + + + + + + + + ); +}; + +export default ImageModal; diff --git a/src/pages/admin/content.tsx b/src/pages/admin/content.tsx index 184de7a..648855f 100644 --- a/src/pages/admin/content.tsx +++ b/src/pages/admin/content.tsx @@ -15,6 +15,7 @@ import LinkModal from '../../components/Dialogs/LinkModal'; import { useSupabase } from '../../utils/supabase'; import { SchemaType } from '../../types/type-util'; import { Space } from '../../components/Space'; +import ImageModal from '../../components/Dialogs/ImageModal'; const Header = styled.div` display: grid; @@ -57,6 +58,7 @@ const AdminContent: React.FC = () => { const deleteDialog = useDisclosure(); const updateDialog = useDisclosure(); const linkDialog = useDisclosure(); + const imageDialog = useDisclosure(); const supabase = useSupabase(); const toast = useToast({ isClosable: true, @@ -258,6 +260,10 @@ const AdminContent: React.FC = () => { setModalData((prev) => ({ ...prev, id: item.id })); linkDialog.onOpen(); }} + onTableImageClick={(item) => { + setModalData((prev) => ({ ...prev, id: item.id })); + imageDialog.onOpen(); + }} />