diff --git a/package.json b/package.json index cb39b7e5..294e8b4f 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ } }, "dependencies": { + "@babel/runtime": "^7.26.0", "@egjs/react-grid": "^1.16.0", "@hello-pangea/dnd": "^17.0.0", "@mui/material": "^5.15.9", @@ -31,6 +32,7 @@ "@tanstack/react-query": "^5.17.12", "@tanstack/react-query-devtools": "^5.17.12", "@types/react-lottie": "^1.2.10", + "@uiw/react-md-editor": "^4.0.4", "@vanilla-extract/dynamic": "^2.1.0", "@vanilla-extract/integration": "^6.2.4", "@vanilla-extract/next-plugin": "^2.3.2", diff --git a/src/app/_api/notice/createNotice.ts b/src/app/_api/notice/createNotice.ts new file mode 100644 index 00000000..57bc951e --- /dev/null +++ b/src/app/_api/notice/createNotice.ts @@ -0,0 +1,14 @@ +import axiosInstance from '@/lib/axios/axiosInstance'; +import { NoticeCreateType } from '@/lib/types/noticeType'; + +interface ResponseType { + id: number; +} + +const createNotice = async (data: NoticeCreateType) => { + const response = await axiosInstance.post('/admin/notices', data); + + return response.data; +}; + +export default createNotice; diff --git a/src/app/_api/notice/getNoticeCategories.ts b/src/app/_api/notice/getNoticeCategories.ts new file mode 100644 index 00000000..f1bfa242 --- /dev/null +++ b/src/app/_api/notice/getNoticeCategories.ts @@ -0,0 +1,10 @@ +import axiosInstance from '@/lib/axios/axiosInstance'; +import { NoticeCategoryType } from '@/lib/types/noticeType'; + +const getNoticeCategories = async () => { + const result = await axiosInstance.get('/admin/notices/categories'); + + return result.data; +}; + +export default getNoticeCategories; diff --git a/src/app/_api/notice/uploadNoticeImages.ts b/src/app/_api/notice/uploadNoticeImages.ts new file mode 100644 index 00000000..ec25800b --- /dev/null +++ b/src/app/_api/notice/uploadNoticeImages.ts @@ -0,0 +1,40 @@ +import axios from 'axios'; +import axiosInstance from '@/lib/axios/axiosInstance'; + +interface UploadImageType { + order: number; + extension: string; +} + +interface UploadNoticeImagesProps { + noticeId: number; + imageExtensionData: UploadImageType[]; + imageFileData: File[]; +} + +interface PresignedResponseType { + order: number; + presignedUrl: string; +} + +const uploadNoticeImages = async ({ noticeId, imageFileData, imageExtensionData }: UploadNoticeImagesProps) => { + // 1. Presigned url 발급 요청 + const presignedResponse = await axiosInstance.post( + `/admin/notices/${noticeId}/presigned-url`, + imageExtensionData + ); + + // 2. 발급 받은 Presigned url로 이미지 업로드 + presignedResponse.data.forEach(async (value, index) => { + await axios.put(value.presignedUrl, imageFileData[index], { + headers: { + 'Content-Type': imageFileData[index].type, + }, + }); + }); + + // 3. 이미지 업로드 완료 서버에 알림 + await axiosInstance.post(`/admin/notices/${noticeId}/upload-complete`, imageExtensionData); +}; + +export default uploadNoticeImages; diff --git a/src/app/admin/notice/create/_components/BlockContainer.css.ts b/src/app/admin/notice/create/_components/BlockContainer.css.ts new file mode 100644 index 00000000..6a43db00 --- /dev/null +++ b/src/app/admin/notice/create/_components/BlockContainer.css.ts @@ -0,0 +1,29 @@ +import { BodyRegular } from '@/styles/font.css'; +import { vars } from '@/styles/theme.css'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + padding: '1rem 1rem', + display: 'flex', + flexDirection: 'column', + gap: '1.5rem', +}); + +export const wrapper = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); + +export const title = style([BodyRegular]); + +export const deleteButton = style({ + color: vars.color.red, +}); + +export const content = style({ + height: '100%', + display: 'flex', + flexDirection: 'column', + gap: '1rem', +}); diff --git a/src/app/admin/notice/create/_components/BlockContainer.tsx b/src/app/admin/notice/create/_components/BlockContainer.tsx new file mode 100644 index 00000000..fbe9de90 --- /dev/null +++ b/src/app/admin/notice/create/_components/BlockContainer.tsx @@ -0,0 +1,53 @@ +import { FieldArrayWithId } from 'react-hook-form'; + +import * as styles from './BlockContainer.css'; + +import { NOTICE_CONTENT } from '@/lib/constants/notice'; +import { NoticeContentsType, NoticeCreateType } from '@/lib/types/noticeType'; +import { BodyContent, ButtonContent, ImageContent, LineContent, NoteContent, SubTitleContent } from './block/index'; + +interface FormAboutContentProps { + type: NoticeContentsType; + order: number; +} + +const formAboutContent = ({ type, order }: FormAboutContentProps) => { + switch (type) { + case 'body': + return ; + case 'subtitle': + return ; + case 'button': + return ; + case 'image': + return ; + case 'line': + return ; + case 'note': + return ; + default: + return null; + } +}; + +interface ContainerProps { + content: FieldArrayWithId; + handleDeleteBlock: (order: number) => void; + order: number; +} + +export default function ContentsContainer({ content, handleDeleteBlock, order }: ContainerProps) { + const { type } = content; + + return ( +
+
+

{NOTICE_CONTENT[type]}

+ +
+
{formAboutContent({ type, order })}
+
+ ); +} diff --git a/src/app/admin/notice/create/_components/CategoryDropdown.css.ts b/src/app/admin/notice/create/_components/CategoryDropdown.css.ts new file mode 100644 index 00000000..f3bebf48 --- /dev/null +++ b/src/app/admin/notice/create/_components/CategoryDropdown.css.ts @@ -0,0 +1,10 @@ +import { BodyBold } from '@/styles/font.css'; +import { style } from '@vanilla-extract/css'; + +export const dropdown = style([ + BodyBold, + { + padding: '0.5rem', + borderRadius: '8px', + }, +]); diff --git a/src/app/admin/notice/create/_components/CategoryDropdown.tsx b/src/app/admin/notice/create/_components/CategoryDropdown.tsx new file mode 100644 index 00000000..50b0be36 --- /dev/null +++ b/src/app/admin/notice/create/_components/CategoryDropdown.tsx @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; +import { useFormContext } from 'react-hook-form'; + +import * as styles from './CategoryDropdown.css'; + +import getNoticeCategories from '@/app/_api/notice/getNoticeCategories'; +import { QUERY_KEYS } from '@/lib/constants/queryKeys'; + +export default function CategoryDropdown() { + const { register } = useFormContext(); + + /** 게시물 카테고리 조회 */ + const { data: categories } = useQuery({ + queryKey: [QUERY_KEYS.getNoticeCategories], + queryFn: getNoticeCategories, + staleTime: Infinity, + }); + + return ( +
+ +
+ ); +} diff --git a/src/app/admin/notice/create/_components/ContentsBody.css.ts b/src/app/admin/notice/create/_components/ContentsBody.css.ts new file mode 100644 index 00000000..2724637c --- /dev/null +++ b/src/app/admin/notice/create/_components/ContentsBody.css.ts @@ -0,0 +1,16 @@ +import { vars } from '@/styles/theme.css'; +import { style } from '@vanilla-extract/css'; + +export const contents = style({ + padding: '1rem 1rem', + display: 'flex', + flexDirection: 'column', + gap: 6, +}); + +export const block = style({ + padding: '0.5rem', + borderRadius: 4, + background: vars.color.bluegray6, + fontSize: 14, +}); diff --git a/src/app/admin/notice/create/_components/ContentsBody.tsx b/src/app/admin/notice/create/_components/ContentsBody.tsx new file mode 100644 index 00000000..78dd2a17 --- /dev/null +++ b/src/app/admin/notice/create/_components/ContentsBody.tsx @@ -0,0 +1,74 @@ +import { useFieldArray, useFormContext } from 'react-hook-form'; + +import * as styles from './ContentsBody.css'; + +import { NOTICE_CONTENT } from '@/lib/constants/notice'; +import { ItemsType, NoticeContentsType } from '@/lib/types/noticeType'; + +import ContentsContainer from './BlockContainer'; + +/** 타입에 따른 Contents 블럭 포멧 지정 유틸 함수 */ +const itemDataFormatByType = (type: NoticeContentsType) => { + const data: ItemsType = { + order: 0, + type, + }; + + switch (type) { + case 'body': + case 'subtitle': + case 'note': + data.description = ''; + break; + case 'button': + data.buttonName = ''; + data.buttonLink = ''; + break; + case 'image': + data.imageUrl = ''; + default: + data; + } + return data; +}; + +export default function ContentsBody() { + const { control } = useFormContext(); + const { fields, append, remove } = useFieldArray({ + name: 'contents', + control, + }); + + const handleAddBlock = (type: NoticeContentsType) => () => { + append(itemDataFormatByType(type)); + }; + + const handleDeleteBlock = (order: number) => { + remove(order); + }; + + return ( + <> +
+ {fields.map((field, index) => ( + + ))} +
+
+ {Object.entries(NOTICE_CONTENT).map(([key, value], index) => ( + + ))} +
+ + ); +} diff --git a/src/app/admin/notice/create/_components/block/BodyContent.tsx b/src/app/admin/notice/create/_components/block/BodyContent.tsx new file mode 100644 index 00000000..07d4abc1 --- /dev/null +++ b/src/app/admin/notice/create/_components/block/BodyContent.tsx @@ -0,0 +1,39 @@ +import { useCallback, useState } from 'react'; +import MDEditor from '@uiw/react-md-editor'; +import { useFormContext } from 'react-hook-form'; + +import * as styles from './index.css'; + +interface BodyContentProps { + order: number; +} + +// TODO security +export default function BodyContent({ order }: BodyContentProps) { + const { setValue } = useFormContext(); + const [text, setText] = useState(''); + + const handleChange = useCallback((value?: string) => { + setText(value as string); + }, []); + + const addContentsBody = () => { + setValue(`contents.${order}.description`, text); + alert('본문을 저장했습니다.'); + }; + + return ( + <> + + + * 본문을 작성하는 경우 반드시 등록 버튼을 눌러 내용을 저장해주세요. + + ); +} diff --git a/src/app/admin/notice/create/_components/block/ButtonContent.tsx b/src/app/admin/notice/create/_components/block/ButtonContent.tsx new file mode 100644 index 00000000..2f90c48d --- /dev/null +++ b/src/app/admin/notice/create/_components/block/ButtonContent.tsx @@ -0,0 +1,34 @@ +import { useFormContext } from 'react-hook-form'; + +import * as styles from './index.css'; + +interface ButtonContentProps { + order: number; +} + +// TODO 버튼 링크 유효성 검사 추가 +export default function ButtonContent({ order }: ButtonContentProps) { + const { register } = useFormContext(); + + return ( + <> +
+ 버튼명 + +
+
+ 링크 + +

+
+ + ); +} diff --git a/src/app/admin/notice/create/_components/block/ImageContent.tsx b/src/app/admin/notice/create/_components/block/ImageContent.tsx new file mode 100644 index 00000000..9fd61c2d --- /dev/null +++ b/src/app/admin/notice/create/_components/block/ImageContent.tsx @@ -0,0 +1,50 @@ +import { ChangeEvent, MouseEvent, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import * as styles from './index.css'; +import ClearBlackIcon from '/public/icons/clear_x_black.svg'; +import AttachedImageIcon from '/public/icons/attach_image.svg'; + +import fileToBase64 from '@/lib/utils/fileToBase64'; + +interface ImageContentProps { + order: number; +} + +export default function ImageContent({ order }: ImageContentProps) { + const fileRef = useRef(null); + const [previewImage, setPreviewImage] = useState(''); + const { setValue } = useFormContext(); + + const handleUploadFile = (e: ChangeEvent) => { + if (e.target.files) { + fileToBase64(e.target.files[0], setPreviewImage); + setValue(`contents.${order}.imageUrl`, e.target.files[0]); + } + e.target.value = ''; // 기존 file value 초기화 + }; + + const handleDeleteImage = (e: MouseEvent) => { + e.stopPropagation(); + setValue(`contents.${order}.imageUrl`, ''); + setPreviewImage(''); + }; + + return ( + <> + {previewImage ? ( +
+ 게시물 이미지 + +
+ ) : ( +
fileRef.current?.click()} className={styles.imageBox.empty}> + +
+ )} + + + ); +} diff --git a/src/app/admin/notice/create/_components/block/LineContent.tsx b/src/app/admin/notice/create/_components/block/LineContent.tsx new file mode 100644 index 00000000..791f9ada --- /dev/null +++ b/src/app/admin/notice/create/_components/block/LineContent.tsx @@ -0,0 +1,3 @@ +export default function LineContent() { + return
구분선이 삽입됩니다.
; +} diff --git a/src/app/admin/notice/create/_components/block/NoteContent.tsx b/src/app/admin/notice/create/_components/block/NoteContent.tsx new file mode 100644 index 00000000..6ed19a2b --- /dev/null +++ b/src/app/admin/notice/create/_components/block/NoteContent.tsx @@ -0,0 +1,21 @@ +import { useFormContext } from 'react-hook-form'; + +import * as styles from './index.css'; + +interface SubTitleContentProps { + order: number; +} + +export default function NoteContent({ order }: SubTitleContentProps) { + const { register } = useFormContext(); + + return ( +
+