diff --git a/frontend/src/apis/category.ts b/frontend/src/apis/category.ts index bd290644c..4026ce329 100644 --- a/frontend/src/apis/category.ts +++ b/frontend/src/apis/category.ts @@ -4,7 +4,12 @@ import { AddCategoriesRequest, PatchCategoryArgs } from 'types/apis/category'; // POST: 카테고리 추가 export const addCategory = (body: AddCategoriesRequest) => - http.post(categoryURL, { body: JSON.stringify(body) }); + http.post(categoryURL, { + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); // GET: 카테고리 목록 조회 export const getCategories = () => http.get(categoryURL); @@ -17,6 +22,9 @@ export const getWritingsInCategory = (categoryId: number | null) => export const patchCategory = ({ categoryId, body }: PatchCategoryArgs) => http.patch(`${categoryURL}/${categoryId}`, { body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, }); // DELETE: 카테고리 삭제 diff --git a/frontend/src/apis/writings.ts b/frontend/src/apis/writings.ts index 7bf8f8ee7..621428301 100644 --- a/frontend/src/apis/writings.ts +++ b/frontend/src/apis/writings.ts @@ -1,6 +1,10 @@ import { writingURL } from 'constants/apis/url'; import { http } from './fetch'; -import type { AddWritingRequest, PublishWritingArgs } from 'types/apis/writings'; +import type { + AddNotionWritingRequest, + AddWritingRequest, + PublishWritingArgs, +} from 'types/apis/writings'; // 글 생성(글 업로드): POST export const addWriting = (body: AddWritingRequest) => @@ -11,6 +15,15 @@ export const addWriting = (body: AddWritingRequest) => // }, }); +// 노션 - 글 생성(글 업로드): POST +export const addNotionWriting = (body: AddNotionWritingRequest) => + http.post(`${writingURL}/notion`, { + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + // 글 조회: GET export const getWriting = (writingId: number) => http.get(`${writingURL}/${writingId}`); diff --git a/frontend/src/assets/icons/import.svg b/frontend/src/assets/icons/import.svg new file mode 100644 index 000000000..340720b4c --- /dev/null +++ b/frontend/src/assets/icons/import.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/icons/index.ts b/frontend/src/assets/icons/index.ts index 01151a7f9..26cb1994f 100644 --- a/frontend/src/assets/icons/index.ts +++ b/frontend/src/assets/icons/index.ts @@ -8,7 +8,7 @@ export { ReactComponent as TagIcon } from './tag.svg'; export { ReactComponent as LeftArrowHeadIcon } from './left-arrow-head.svg'; export { ReactComponent as TistoryLogoIcon } from './tistory-logo.svg'; export { ReactComponent as MediumLogoIcon } from './medium-logo.svg'; -export { ReactComponent as CloseRounded } from './close-rounded.svg'; +export { ReactComponent as CloseIcon } from './close-rounded.svg'; export { ReactComponent as SidebarLeftIcon } from './sidebar-left.svg'; export { ReactComponent as SidebarRightIcon } from './sidebar-right.svg'; export { ReactComponent as SettingIcon } from './setting.svg'; @@ -18,3 +18,4 @@ export { ReactComponent as ArrowRightIcon } from './arrow-right.svg'; export { ReactComponent as DeleteIcon } from './delete.svg'; export { ReactComponent as PencilIcon } from './pencil.svg'; export { ReactComponent as WritingIcon } from './writing.svg'; +export { ReactComponent as ImportIcon } from './import.svg'; diff --git a/frontend/src/components/@common/FileUploader/FileUploader.stories.tsx b/frontend/src/components/@common/FileUploader/FileUploader.stories.tsx new file mode 100644 index 000000000..6fb428fcb --- /dev/null +++ b/frontend/src/components/@common/FileUploader/FileUploader.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import FileUploader from './FileUploader'; + +const meta: Meta = { + title: 'common/FileUploader', + component: FileUploader, +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: () => { + const onFileSelect = (file: FormData | null) => { + if (file) alert('파일이 선택되었습니다!'); + }; + return ; + }, +}; diff --git a/frontend/src/components/@common/FileUploader/FileUploader.tsx b/frontend/src/components/@common/FileUploader/FileUploader.tsx new file mode 100644 index 000000000..3aa5f593b --- /dev/null +++ b/frontend/src/components/@common/FileUploader/FileUploader.tsx @@ -0,0 +1,77 @@ +import { InputHTMLAttributes, useEffect } from 'react'; +import { css, styled } from 'styled-components'; +import { useFileUpload } from 'hooks/useFileUpload'; +import { useFileDragAndDrop } from 'hooks/@common/useFileDragAndDrop'; +import { ImportIcon } from 'assets/icons'; + +type Props = { + accept?: InputHTMLAttributes['accept']; + width?: string; + height?: string; + onFileSelect: (file: FormData | null) => void; +}; + +const FileUploader = ({ accept = '*', width = '30rem', height = '10rem', onFileSelect }: Props) => { + const { onFileChange, openFinder, selectedFile } = useFileUpload(accept); + const { dragRef, isDragging } = useFileDragAndDrop({ onFileChange }); + + useEffect(() => { + onFileSelect(selectedFile); + }, [selectedFile]); + + return ( + + ); +}; + +export default FileUploader; + +const S = { + Description: styled.div<{ $isDragging: boolean; $width: string; $height: string }>` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1rem; + ${({ $width, $height }) => { + return css` + width: ${$width}; + height: ${$height}; + `; + }}; + border: 2px dashed ${({ theme }) => theme.color.gray6}; + background-color: ${({ theme }) => theme.color.gray4}; + font-size: 1.3rem; + color: ${({ theme }) => theme.color.gray7}; + transition: all 0.2s ease-in-out; + + ${({ $isDragging, theme }) => { + return ( + $isDragging && + css` + border: 2px dashed ${theme.color.primary}; + background-color: ${theme.color.gray5}; + ` + ); + }} + &:hover { + background-color: ${({ theme }) => theme.color.gray5}; + } + `, + SpinnerWrapper: styled.div<{ $width: string; $height: string }>` + ${({ $width, $height }) => { + return css` + width: ${$width}; + height: ${$height}; + `; + }}; + + display: flex; + justify-content: center; + `, +}; diff --git a/frontend/src/components/@common/Input/Input.tsx b/frontend/src/components/@common/Input/Input.tsx index 65d0547eb..6a320dbf3 100644 --- a/frontend/src/components/@common/Input/Input.tsx +++ b/frontend/src/components/@common/Input/Input.tsx @@ -146,6 +146,10 @@ const S = { ${({ $size }) => genSizeStyle($size)}; ${({ $variant, $isError }) => genVariantStyle($variant, $isError)}; + + &::placeholder { + color: ${({ theme }) => theme.color.gray6}; + } `, SupportingText: styled.p<{ $isError: boolean | undefined }>` color: ${({ $isError, theme }) => ($isError ? theme.color.red6 : theme.color.gray7)}; diff --git a/frontend/src/components/@common/Modal/Modal.stories.tsx b/frontend/src/components/@common/Modal/Modal.stories.tsx new file mode 100644 index 000000000..bc8fc772b --- /dev/null +++ b/frontend/src/components/@common/Modal/Modal.stories.tsx @@ -0,0 +1,56 @@ +import { Meta, StoryObj } from '@storybook/react'; +import Modal from './Modal'; +import Button from 'components/@common/Button/Button'; +import { useModal } from 'hooks/@common/useModal'; +import { styled } from 'styled-components'; + +const meta = { + title: 'common/Modal', + component: Modal, + argTypes: { + children: { + control: false, + }, + isOpen: { + control: false, + }, + }, + args: { + isOpen: false, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: ({ ...rest }) => { + const { isOpen, openModal, closeModal } = useModal(); + return ( + <> + + + +

모달

+

내용을 마음껏 써주세요.

+
+
+ + ); + }, +}; + +const ModalContent = styled.div` + display: flex; + flex-direction: column; + align-items: center; + + height: 30rem; + p { + margin-top: 13rem; + font-size: 1.3rem; + } +`; diff --git a/frontend/src/components/@common/Modal/Modal.tsx b/frontend/src/components/@common/Modal/Modal.tsx new file mode 100644 index 000000000..e2a8fb3e0 --- /dev/null +++ b/frontend/src/components/@common/Modal/Modal.tsx @@ -0,0 +1,81 @@ +import { useCallback, useEffect } from 'react'; +import { ComponentPropsWithoutRef } from 'react'; +import { createPortal } from 'react-dom'; +import { styled } from 'styled-components'; +import { CloseIcon } from 'assets/icons'; + +type Props = { + isOpen: boolean; + closeModal: () => void; +} & ComponentPropsWithoutRef<'dialog'>; + +const Modal = ({ isOpen = true, closeModal, children, ...rest }: Props) => { + const onKeyDownEscape = useCallback( + (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + closeModal(); + }, + [closeModal], + ); + + useEffect(() => { + if (isOpen) { + window.addEventListener('keydown', onKeyDownEscape); + document.body.style.overflow = 'hidden'; + } + + return () => { + window.removeEventListener('keydown', onKeyDownEscape); + document.body.style.overflow = 'auto'; + }; + }, [isOpen, onKeyDownEscape]); + + return createPortal( + + {isOpen && ( + <> + + + + + + {children} + + + )} + , + document.body, + ); +}; + +export default Modal; + +const S = { + ModalWrapper: styled.div` + position: relative; + z-index: 9999; + `, + Backdrop: styled.div` + position: fixed; + inset: 0; + background: ${({ theme }) => theme.color.modalBackdrop}; + `, + Content: styled.dialog` + position: fixed; + inset: 50% auto auto 50%; + display: flex; + justify-content: center; + min-width: 20vw; + max-height: 80vh; + overflow: auto; + padding: 2.5rem; + border: none; + border-radius: 8px; + background-color: ${({ theme }) => theme.color.gray1}; + transform: translate(-50%, -50%); + `, + CloseButton: styled.button` + position: absolute; + inset: 2.5rem 2.5rem auto auto; + `, +}; diff --git a/frontend/src/components/@common/Tag/Tag.tsx b/frontend/src/components/@common/Tag/Tag.tsx index b9983b843..6e1ca8e36 100644 --- a/frontend/src/components/@common/Tag/Tag.tsx +++ b/frontend/src/components/@common/Tag/Tag.tsx @@ -1,6 +1,6 @@ -import { CloseRounded } from 'assets/icons'; -import { ComponentPropsWithoutRef, PropsWithChildren } from 'react'; +import { ComponentPropsWithoutRef } from 'react'; import { styled } from 'styled-components'; +import { CloseIcon } from 'assets/icons'; type Props = { removable?: boolean; @@ -10,7 +10,7 @@ const Tag = ({ removable = true, children, ...rest }: Props) => { return ( #{children} - {removable && } + {removable && } ); }; diff --git a/frontend/src/components/FileUploadModal/FileUploadModal.tsx b/frontend/src/components/FileUploadModal/FileUploadModal.tsx new file mode 100644 index 000000000..e69e21ff9 --- /dev/null +++ b/frontend/src/components/FileUploadModal/FileUploadModal.tsx @@ -0,0 +1,88 @@ +import Button from 'components/@common/Button/Button'; +import FileUploader from 'components/@common/FileUploader/FileUploader'; +import Modal from 'components/@common/Modal/Modal'; +import Spinner from 'components/@common/Spinner/Spinner'; +import { styled } from 'styled-components'; +import { useFileUploadModal } from './useFileUploadModal'; +import Input from 'components/@common/Input/Input'; + +type Props = { + isOpen: boolean; + closeModal: () => void; +}; + +const FileUploadModal = ({ isOpen, closeModal }: Props) => { + const { isLoading, inputValue, uploadOnServer, setNotionPageLink, uploadNotionWriting } = + useFileUploadModal({ closeModal }); + + return ( + + + 글 가져오기 + {isLoading ? ( + <> + 글 가져오는 중... + + + ) : ( + + + 내 컴퓨터에서 가져오기 + + + + 노션에서 가져오기 + + + + + )} + + + ); +}; + +export default FileUploadModal; + +const S = { + Container: styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 3rem; + width: 50vw; + max-width: 40rem; + `, + Title: styled.h1` + font-size: 2rem; + font-weight: 700; + `, + Content: styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 7rem; + height: 100%; + margin: 2rem 0; + font-size: 1.3rem; + `, + Item: styled.div` + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; + `, + ItemTitle: styled.h2` + font-size: 1.4rem; + font-weight: 500; + `, +}; diff --git a/frontend/src/components/FileUploadModal/useFileUploadModal.ts b/frontend/src/components/FileUploadModal/useFileUploadModal.ts new file mode 100644 index 000000000..571a813b3 --- /dev/null +++ b/frontend/src/components/FileUploadModal/useFileUploadModal.ts @@ -0,0 +1,61 @@ +import { addNotionWriting, addWriting } from 'apis/writings'; +import useMutation from 'hooks/@common/useMutation'; +import { usePageNavigate } from 'hooks/usePageNavigate'; +import { ChangeEventHandler, useState } from 'react'; +import { AddWritingRequest } from 'types/apis/writings'; + +type Args = { + closeModal: () => void; +}; + +export const useFileUploadModal = ({ closeModal }: Args) => { + const [inputValue, setInputValue] = useState(''); + const { goWritingPage } = usePageNavigate(); + + const { mutateQuery: uploadNotion, isLoading: isNotionUploadLoading } = useMutation({ + fetcher: addNotionWriting, + onSuccess: (data) => onFileUploadSuccess(data.headers), + }); + const { mutateQuery: uploadFile, isLoading: isFileUploadLoading } = useMutation< + AddWritingRequest, + null + >({ + fetcher: addWriting, + onSuccess: (data) => onFileUploadSuccess(data.headers), + }); + + const onFileUploadSuccess = (headers: Headers) => { + const writingId = headers.get('Location')?.split('/').pop(); + goWritingPage(Number(writingId)); + closeModal(); + }; + + const uploadOnServer = async (selectedFile: FormData | null) => { + if (!selectedFile) return; + + selectedFile.append('categoryId', JSON.stringify(1)); + + await uploadFile(selectedFile); + }; + + const setNotionPageLink: ChangeEventHandler = (e) => { + setInputValue(e.target.value); + }; + + const uploadNotionWriting = async () => { + const blockId = inputValue.split('/')?.pop()?.split('?')?.shift()?.split('-').pop(); + + if (!blockId) return; + + setInputValue(''); + + await uploadNotion({ + blockId: blockId, + categoryId: 1, + }); + }; + + const isLoading = isNotionUploadLoading || isFileUploadLoading; + + return { isLoading, inputValue, uploadOnServer, setNotionPageLink, uploadNotionWriting }; +}; diff --git a/frontend/src/hooks/@common/useFileDragAndDrop.ts b/frontend/src/hooks/@common/useFileDragAndDrop.ts new file mode 100644 index 000000000..f5b4106f6 --- /dev/null +++ b/frontend/src/hooks/@common/useFileDragAndDrop.ts @@ -0,0 +1,63 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +type Args = { + onFileChange: (e: React.DragEvent | Event) => void; +}; + +export const useFileDragAndDrop = ({ onFileChange }: Args) => { + const [isDragging, setIsDragging] = useState(false); + const dragRef = useRef(null); + + const onDragEnter = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const onDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer?.files) { + setIsDragging(true); + } + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + onFileChange(e); + setIsDragging(false); + }; + + const initDragEvents = useCallback((): void => { + if (dragRef.current) { + dragRef.current.addEventListener('dragenter', onDragEnter); + dragRef.current.addEventListener('dragleave', onDragLeave); + dragRef.current.addEventListener('dragover', onDragOver); + dragRef.current.addEventListener('drop', onDrop); + } + }, [onDragEnter, onDragLeave, onDragOver, onDrop]); + + const resetDragEvents = useCallback((): void => { + if (dragRef.current) { + dragRef.current.removeEventListener('dragenter', onDragEnter); + dragRef.current.removeEventListener('dragleave', onDragLeave); + dragRef.current.removeEventListener('dragover', onDragOver); + dragRef.current.removeEventListener('drop', onDrop); + } + }, [onDragEnter, onDragLeave, onDragOver, onDrop]); + + useEffect(() => { + initDragEvents(); + + return () => resetDragEvents(); + }, [initDragEvents, resetDragEvents]); + + return { dragRef, isDragging }; +}; diff --git a/frontend/src/hooks/@common/useModal.ts b/frontend/src/hooks/@common/useModal.ts new file mode 100644 index 000000000..fb3a5688b --- /dev/null +++ b/frontend/src/hooks/@common/useModal.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; + +export const useModal = () => { + const [isOpen, setIsOpen] = useState(false); + + const openModal = () => { + setIsOpen(true); + }; + + const closeModal = () => { + setIsOpen(false); + }; + + return { + isOpen, + openModal, + closeModal, + }; +}; diff --git a/frontend/src/hooks/useFileUpload.ts b/frontend/src/hooks/useFileUpload.ts index ae2b3deb0..8e263dfc7 100644 --- a/frontend/src/hooks/useFileUpload.ts +++ b/frontend/src/hooks/useFileUpload.ts @@ -1,23 +1,11 @@ -import { InputHTMLAttributes, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import useMutation from './@common/useMutation'; -import { addWriting } from 'apis/writings'; -import { AddWritingRequest } from 'types/apis/writings'; +import { InputHTMLAttributes, useState } from 'react'; export const useFileUpload = (accept: InputHTMLAttributes['accept'] = '*') => { const [selectedFile, setSelectedFile] = useState(null); - const { mutateQuery } = useMutation({ - fetcher: (body) => addWriting(body), - onSuccess: (data) => { - const writingId = data.headers.get('Location')?.split('/').pop(); - navigate(`/writing/${writingId}`); - }, - }); - - const navigate = useNavigate(); - - const onFileChange = (e: Event) => { - const target = e.target as HTMLInputElement; + + const onFileChange = (e: React.DragEvent | Event) => { + const target = 'dataTransfer' in e ? e.dataTransfer : (e.target as HTMLInputElement); + if (!target.files) return; const newFile = target.files[0]; @@ -28,12 +16,6 @@ export const useFileUpload = (accept: InputHTMLAttributes['acc setSelectedFile(formData); }; - const uploadOnServer = async (selectedFile: FormData | null) => { - if (!selectedFile) return; - - await mutateQuery(selectedFile); - }; - const openFinder = () => { const fileInput = document.createElement('input'); fileInput.type = 'file'; @@ -42,9 +24,5 @@ export const useFileUpload = (accept: InputHTMLAttributes['acc fileInput.click(); }; - useEffect(() => { - uploadOnServer(selectedFile); - }, [selectedFile]); - - return { openFinder }; + return { selectedFile, openFinder, onFileChange }; }; diff --git a/frontend/src/mocks/handlers/writing.ts b/frontend/src/mocks/handlers/writing.ts index 041dd5706..dedd95800 100644 --- a/frontend/src/mocks/handlers/writing.ts +++ b/frontend/src/mocks/handlers/writing.ts @@ -57,7 +57,12 @@ export const writingHandlers = [ // 글 생성(글 업로드): POST rest.post(`${writingURL}/file`, async (_, res, ctx) => { - return res(ctx.status(201), ctx.set('Location', `/writings/200`)); + return res(ctx.delay(3000), ctx.status(201), ctx.set('Location', `/writings/200`)); + }), + + // 글 생성(글 업로드): POST + rest.post(`${writingURL}/notion`, async (_, res, ctx) => { + return res(ctx.delay(3000), ctx.status(201), ctx.set('Location', `/writings/200`)); }), // 글 블로그로 발행: POST diff --git a/frontend/src/pages/Layout/Layout.tsx b/frontend/src/pages/Layout/Layout.tsx index acb2fb335..2f06d5a5f 100644 --- a/frontend/src/pages/Layout/Layout.tsx +++ b/frontend/src/pages/Layout/Layout.tsx @@ -3,13 +3,13 @@ import { Outlet, useOutletContext } from 'react-router-dom'; import { styled } from 'styled-components'; import { PlusCircleIcon } from 'assets/icons'; import Button from 'components/@common/Button/Button'; -import { useFileUpload } from 'hooks/useFileUpload'; - import { HEADER_STYLE, LAYOUT_STYLE, sidebarStyle } from 'styles/layoutStyle'; import Header from 'components/Header/Header'; import { usePageNavigate } from 'hooks/usePageNavigate'; import WritingSideBar from 'components/WritingSideBar/WritingSideBar'; import CategorySection from 'components/Category/CategorySection/CategorySection'; +import { useModal } from 'hooks/@common/useModal'; +import FileUploadModal from 'components/FileUploadModal/FileUploadModal'; export type PageContext = { isLeftSidebarOpen?: boolean; @@ -21,7 +21,7 @@ const Layout = () => { const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true); const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true); const [activeWritingId, setActiveWritingId] = useState(null); - const { openFinder } = useFileUpload('.md'); + const { isOpen, openModal, closeModal } = useModal(); const isWritingViewerActive = activeWritingId !== null; const toggleLeftSidebar = () => { @@ -47,11 +47,11 @@ const Layout = () => { icon={} block={true} align='left' - onClick={openFinder} + onClick={openModal} > Add Post - + diff --git a/frontend/src/styles/layoutStyle.ts b/frontend/src/styles/layoutStyle.ts index 8125b6426..6e88080fc 100644 --- a/frontend/src/styles/layoutStyle.ts +++ b/frontend/src/styles/layoutStyle.ts @@ -2,11 +2,11 @@ import { RuleSet, css } from 'styled-components'; import { theme } from './theme'; export const HEADER_STYLE = { - height: '3rem', + height: '4rem', } as const; export const LAYOUT_STYLE = { - padding: '1rem', + padding: '0 1rem 1rem 1rem', border: `2px solid ${theme.color.gray13}`, gap: '0.4rem', } as const; diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts index b9ffa1396..0f1c04c95 100644 --- a/frontend/src/styles/theme.ts +++ b/frontend/src/styles/theme.ts @@ -5,6 +5,8 @@ const color = { tistory: '#FF5A4A', medium: '#000000', + modalBackdrop: '#00000059', + red1: '#fff1f0', red2: '#ffccc7', red3: '#ffa39e', diff --git a/frontend/src/types/apis/writings.ts b/frontend/src/types/apis/writings.ts index 96186358a..08c330600 100644 --- a/frontend/src/types/apis/writings.ts +++ b/frontend/src/types/apis/writings.ts @@ -2,6 +2,11 @@ import { Blog, PublishingPropertyData } from 'types/domain'; export type AddWritingRequest = FormData; +export type AddNotionWritingRequest = { + blockId: string; + categoryId: number; +}; + export type GetWritingResponse = { id: number; title: string;