diff --git a/package.json b/package.json index f80a80f0..294e8b4f 100644 --- a/package.json +++ b/package.json @@ -32,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/deleteNotice.ts b/src/app/_api/notice/deleteNotice.ts new file mode 100644 index 00000000..9ebcda99 --- /dev/null +++ b/src/app/_api/notice/deleteNotice.ts @@ -0,0 +1,7 @@ +import axiosInstance from '@/lib/axios/axiosInstance'; + +const deleteNotice = async (noticeId: number) => { + await axiosInstance.delete(`/admin/notices/${noticeId}`); +}; + +export default deleteNotice; diff --git a/src/app/_api/notice/getAdminNotices.ts b/src/app/_api/notice/getAdminNotices.ts new file mode 100644 index 00000000..4799f6ed --- /dev/null +++ b/src/app/_api/notice/getAdminNotices.ts @@ -0,0 +1,10 @@ +import axiosInstance from '@/lib/axios/axiosInstance'; +import { AdminNoticeType } from '@/lib/types/noticeType'; + +const getAdminNotices = async () => { + const result = await axiosInstance.get('/admin/notices'); + + return result.data; +}; + +export default getAdminNotices; 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/getNoticeDetail.ts b/src/app/_api/notice/getNoticeDetail.ts new file mode 100644 index 00000000..a7813216 --- /dev/null +++ b/src/app/_api/notice/getNoticeDetail.ts @@ -0,0 +1,10 @@ +import axiosInstance from '@/lib/axios/axiosInstance'; +import { NoticeDetailType } from '@/lib/types/noticeType'; + +const getNoticeDetail = async (noticeId: number) => { + const result = await axiosInstance.get(`/notices/${noticeId}`); + + return result.data; +}; + +export default getNoticeDetail; diff --git a/src/app/_api/notice/sendNoticeAlarm.ts b/src/app/_api/notice/sendNoticeAlarm.ts new file mode 100644 index 00000000..d4720e5f --- /dev/null +++ b/src/app/_api/notice/sendNoticeAlarm.ts @@ -0,0 +1,7 @@ +import axiosInstance from '@/lib/axios/axiosInstance'; + +const sendNoticeAlarm = async (noticeId: number) => { + await axiosInstance.post(`/admin/notices/${noticeId}/alarm`); +}; + +export default sendNoticeAlarm; diff --git a/src/app/_api/notice/updateNoticePublic.ts b/src/app/_api/notice/updateNoticePublic.ts new file mode 100644 index 00000000..7e3b656b --- /dev/null +++ b/src/app/_api/notice/updateNoticePublic.ts @@ -0,0 +1,7 @@ +import axiosInstance from '@/lib/axios/axiosInstance'; + +const updateNoticePublic = async (noticeId: number) => { + await axiosInstance.patch(`/admin/notices/${noticeId}`); +}; + +export default updateNoticePublic; 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/layout.css.ts b/src/app/admin/layout.css.ts new file mode 100644 index 00000000..e2c003a5 --- /dev/null +++ b/src/app/admin/layout.css.ts @@ -0,0 +1,40 @@ +import { style } from '@vanilla-extract/css'; +import { Header, BodyRegular } from '@/styles/font.css'; +import { vars } from '@/styles/theme.css'; + +export const container = style({ + minHeight: '100vh', + display: 'flex', +}); + +export const nav = style({ + padding: '1.5rem', + + display: 'flex', + flexDirection: 'column', + gap: '3rem', + + borderRight: '2px solid', + borderRightColor: vars.color.bluegray6, +}); + +export const title = style([ + Header, + { + color: vars.color.bluegray8, + }, +]); + +export const menu = style([ + BodyRegular, + { + width: 200, + display: 'flex', + flexDirection: 'column', + gap: '2rem', + }, +]); + +export const main = style({ + flexGrow: 1, +}); diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 00000000..fcb7940f --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,23 @@ +import { ReactNode } from 'react'; + +import * as styles from './layout.css'; +import Link from 'next/link'; + +interface AdminNoticeLayoutProps { + children: ReactNode; +} + +export default function AdminNoticeLayout({ children }: AdminNoticeLayoutProps) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/app/admin/notice/_components/NoticeItem.css.ts b/src/app/admin/notice/_components/NoticeItem.css.ts new file mode 100644 index 00000000..1be24153 --- /dev/null +++ b/src/app/admin/notice/_components/NoticeItem.css.ts @@ -0,0 +1,66 @@ +import { style, styleVariants } from '@vanilla-extract/css'; +import { vars } from '@/styles/theme.css'; +import { Label } from '@/styles/font.css'; + +export const bodyRow = style([ + Label, + { + padding: '1rem 0.5rem', + marginBottom: '1rem', + borderBottom: `1px solid ${vars.color.bluegray6}`, + + display: 'grid', + gridTemplateColumns: 'repeat(8, 1fr)', + alignItems: 'center', + + textAlign: 'center', + }, +]); + +export const rowItem = style({ + gridColumn: 'span 2', + + display: 'flex', + flexDirection: 'column', + gap: '0.5rem', +}); + +export const rowText = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +export const buttons = style({ + display: 'flex', + justifyContent: 'center', + gap: '0.5rem', +}); + +const button = style({ + padding: '0.5rem 1rem', + borderRadius: '4px', + backgroundColor: vars.color.blue, + color: vars.color.white, + + ':hover': { + opacity: 0.7, + }, +}); + +export const variantsButton = styleVariants({ + default: [button], + disabled: [ + button, + { + opacity: 0.7, + cursor: 'default', + }, + ], +}); + +export const modal = style({ + width: '100%', + height: '100vh', + overflow: 'scroll', +}); diff --git a/src/app/admin/notice/_components/NoticeItem.tsx b/src/app/admin/notice/_components/NoticeItem.tsx new file mode 100644 index 00000000..b18ba071 --- /dev/null +++ b/src/app/admin/notice/_components/NoticeItem.tsx @@ -0,0 +1,111 @@ +import { useQuery } from '@tanstack/react-query'; + +import * as styles from './NoticeItem.css'; + +import useNotice from '@/hooks/queries/useNotice'; +import useBooleanOutput from '@/hooks/useBooleanOutput'; + +import Modal from '@/components/Modal/Modal'; +import NoticeDetailInfo from '@/components/NoticeDetail/NoticeDetailInfo'; + +import { AdminNoticeType, NoticeDetailType } from '@/lib/types/noticeType'; +import formatDate from '@/lib/utils/dateFormat'; +import { QUERY_KEYS } from '@/lib/constants/queryKeys'; + +import getNoticeDetail from '@/app/_api/notice/getNoticeDetail'; + +interface NoticeDetailModalProps { + noticeId: number; +} + +function NoticeDetailModal({ noticeId }: NoticeDetailModalProps) { + const { data: notices } = useQuery({ + queryKey: [QUERY_KEYS.getNoticeDetail], + queryFn: () => getNoticeDetail(noticeId), + enabled: !!noticeId, + }); + + return <>{notices && }; +} + +interface NoticeItemProps { + notice: AdminNoticeType; +} + +function NoticeItem({ notice }: NoticeItemProps) { + const { deleteNoticeMutation, sendNoticeAlarmMutation, updateNoticePublicMutation } = useNotice(); + const { isOn, handleSetOn, handleSetOff } = useBooleanOutput(); + + const { id, title, description, didSendAlarm, isExposed, category, createdDate } = notice; + + const handleDeleteNotice = () => { + deleteNoticeMutation.mutate(id); + }; + + const handleSendAlarm = () => { + if (!isExposed) { + alert('공개 게시물만 알림을 보낼 수 있어요.'); + return; + } + if (didSendAlarm) { + alert('이미 알림을 보낸 게시물입니다.'); + return; + } + sendNoticeAlarmMutation.mutate(id, { + onSuccess: () => alert('알림을 보냈어요.'), + }); + }; + + const handleTogglePublic = () => { + updateNoticePublicMutation.mutate(id); + }; + + return ( + <> + + {formatDate(createdDate)} + {category} + + {title} + {description} + + + + + + + + + + + + + + + + + {isOn && ( + +
+ +
+
+ )} + + ); +} + +export default NoticeItem; 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 ( +
+