Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: (어드민) 게시물 생성 페이지 및 기능 구현 #276

Merged
merged 37 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ec9776f
Feat: 게시물 종류에 따른 카테고리 조회 API 연동 및 어드민 페이지 추가
ParkSohyunee Nov 6, 2024
7315788
Feat: 게시물 제목, 소개 UI 마크업 및 react-hook-form 연결
ParkSohyunee Nov 6, 2024
1cbf9cd
feat: 추가할 콘텐츠 블록을 클릭했을 때 해당 콘텐츠에 해당하는 form이 렌더링되도록 구현
ParkSohyunee Nov 7, 2024
2b91a3d
Feat: 본문 컨텐츠에 마크다운 에디터 설치 및 적용
ParkSohyunee Nov 15, 2024
71e9123
Feat: 리액트훅폼 FormProvider로 전역상태 관리 및 마크다운 에디터 값이 저장되도록 구현
ParkSohyunee Nov 15, 2024
fa3f7bc
Feat: 리액트훅폼 useFieldArray 함수로 item 항목 추가 및 삭제 구현
ParkSohyunee Nov 16, 2024
29a29e2
Feat: 마크다운 에디터 값 저장 로직 수정
ParkSohyunee Nov 16, 2024
779d67a
Rename: 컴포넌트 이름 오타 수정
ParkSohyunee Nov 16, 2024
0a92502
Fix: 게시물 item 타입 수정에 따른 게시물/공지 조회 타입 오류 수정
ParkSohyunee Nov 17, 2024
98e7c3c
Feat: 콘텐츠 타입에 따른 블럭 추가 포맷 로직 구현
ParkSohyunee Nov 17, 2024
7dced77
Feat: 콘텐츠 블록 - 소제목, 구분선, 유의사항 컴포넌트 추가
ParkSohyunee Nov 17, 2024
71ff33b
Design: 게시물 조회 시 유의사항 타입일때 스타일 수정
ParkSohyunee Nov 17, 2024
7a818b0
Feat: 버튼 콘텐츠 블럭 추가
ParkSohyunee Nov 17, 2024
60339f3
Feat: 게시물 이미지 업로드 기능 구현
ParkSohyunee Nov 18, 2024
1b3ee89
Feat: 리스트 생성하기 API 연동
ParkSohyunee Nov 20, 2024
0e436c0
Feat: presigned-url을 사용해서 이미지 업로드 기능 완성
ParkSohyunee Nov 21, 2024
f8d2741
Feat: 게시물 제목, 소개 유효성 검사 추가 및 유효성 검사에 따른 저장버튼 활성화 스타일 적용
ParkSohyunee Nov 21, 2024
b4fe77a
Fix: 게시물 콘텐츠 이미지 타입 수정
ParkSohyunee Nov 21, 2024
7fe84a4
Refactor: 게시물 카테고리, 콘텐츠 블럭 컴포넌트 분리 및 파일 구조 수정
ParkSohyunee Nov 21, 2024
caf73af
Refactor: 콘텐츠 블록 컴포넌트 분리 및 유틸함수 정리
ParkSohyunee Nov 21, 2024
853f5f7
Fix: 이미지 업로드 시 기존 file value 초기화 적용
ParkSohyunee Nov 21, 2024
462363b
Design: 기타 스타일 수정
ParkSohyunee Nov 21, 2024
6212194
Merge branch 'dev' into feature/admin-notice
ParkSohyunee Nov 21, 2024
1a6220c
Design: 글로벌 스타일 수정에 따른 홈(home) 페이지 스타일 수정
ParkSohyunee Nov 21, 2024
8a46dfe
Design: 글로벌 스타일 수정에 따른 마이피드 페이지, 콜렉션 페이지 스타일 수정
ParkSohyunee Nov 21, 2024
9ef3a85
Design: 글로벌 스타일 수정에 따른 설정페이지, 프로필수정 페이지, 탈퇴페이지 스타일 수정
ParkSohyunee Nov 21, 2024
2dee32e
Design: 글로벌 스타일 수정에 따른 요청 주제 페이지, 탈퇴 알림페이지, 온보딩 페이지, 알림페이지 스타일 수정
ParkSohyunee Nov 21, 2024
46d959c
Revert "Design: 글로벌 스타일 수정에 따른 요청 주제 페이지, 탈퇴 알림페이지, 온보딩 페이지, 알림페이지 스타…
ParkSohyunee Nov 21, 2024
9a26123
Revert "Design: 글로벌 스타일 수정에 따른 설정페이지, 프로필수정 페이지, 탈퇴페이지 스타일 수정"
ParkSohyunee Nov 21, 2024
7865cd2
Revert "Design: 글로벌 스타일 수정에 따른 마이피드 페이지, 콜렉션 페이지 스타일 수정"
ParkSohyunee Nov 21, 2024
70daf23
Revert "Design: 글로벌 스타일 수정에 따른 홈(home) 페이지 스타일 수정"
ParkSohyunee Nov 21, 2024
58e7443
Design: 어드민 페이지 넓이 및 에디터 리스트 스타일 적용을 위한 글로벌 스타일 수정
ParkSohyunee Nov 21, 2024
86d380a
Chore: 패키지 재설치에 따른 yarn.lock 파일 업데이트
ParkSohyunee Nov 21, 2024
9f5d2ce
Chore: 타입스크립트 관련 빌드 에러 해결을 위해 node_modules 삭제 후 재설치로 인한 yarn.lock 파일 수정
ParkSohyunee Nov 22, 2024
47e8922
Chore: react-lottie 라이브러리에서 종속성 패키지를 업데이트함에 따른 빌드 에러 발생을 해결하기 위한 babe…
ParkSohyunee Nov 22, 2024
70d0522
Chore: yarn.lock 파일 업데이트
ParkSohyunee Nov 26, 2024
5dae627
Chore: @uiw/react-md-editor 라이브러리 재설치
ParkSohyunee Nov 26, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@
}
},
"dependencies": {
"@babel/runtime": "^7.26.0",
"@egjs/react-grid": "^1.16.0",
"@hello-pangea/dnd": "^17.0.0",
"@mui/material": "^5.15.9",
"@next/third-parties": "^14.1.0",
"@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",
Expand Down
14 changes: 14 additions & 0 deletions src/app/_api/notice/createNotice.ts
Original file line number Diff line number Diff line change
@@ -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<ResponseType>('/admin/notices', data);

return response.data;
};

export default createNotice;
10 changes: 10 additions & 0 deletions src/app/_api/notice/getNoticeCategories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import axiosInstance from '@/lib/axios/axiosInstance';
import { NoticeCategoryType } from '@/lib/types/noticeType';

const getNoticeCategories = async () => {
const result = await axiosInstance.get<NoticeCategoryType[]>('/admin/notices/categories');

return result.data;
};

export default getNoticeCategories;
40 changes: 40 additions & 0 deletions src/app/_api/notice/uploadNoticeImages.ts
Original file line number Diff line number Diff line change
@@ -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<PresignedResponseType[]>(
`/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;
29 changes: 29 additions & 0 deletions src/app/admin/notice/create/_components/BlockContainer.css.ts
Original file line number Diff line number Diff line change
@@ -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',
});
53 changes: 53 additions & 0 deletions src/app/admin/notice/create/_components/BlockContainer.tsx
Original file line number Diff line number Diff line change
@@ -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 <BodyContent order={order} />;
case 'subtitle':
return <SubTitleContent order={order} />;
case 'button':
return <ButtonContent order={order} />;
case 'image':
return <ImageContent order={order} />;
case 'line':
return <LineContent />;
case 'note':
return <NoteContent order={order} />;
default:
return null;
}
};

interface ContainerProps {
content: FieldArrayWithId<NoticeCreateType, 'contents', 'id'>;
handleDeleteBlock: (order: number) => void;
order: number;
}

export default function ContentsContainer({ content, handleDeleteBlock, order }: ContainerProps) {
const { type } = content;

return (
<div className={styles.container}>
<div className={styles.wrapper}>
<h3 className={styles.title}>{NOTICE_CONTENT[type]}</h3>
<button type="button" onClick={() => handleDeleteBlock(order)} className={styles.deleteButton}>
삭제
</button>
</div>
<div className={styles.content}>{formAboutContent({ type, order })}</div>
</div>
);
}
10 changes: 10 additions & 0 deletions src/app/admin/notice/create/_components/CategoryDropdown.css.ts
Original file line number Diff line number Diff line change
@@ -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',
},
]);
30 changes: 30 additions & 0 deletions src/app/admin/notice/create/_components/CategoryDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<select {...register('categoryCode')} className={styles.dropdown}>
{categories?.map((category) => (
<option key={category.code} value={category.code}>
{category.viewName}
</option>
))}
</select>
</div>
);
}
16 changes: 16 additions & 0 deletions src/app/admin/notice/create/_components/ContentsBody.css.ts
Original file line number Diff line number Diff line change
@@ -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,
});
74 changes: 74 additions & 0 deletions src/app/admin/notice/create/_components/ContentsBody.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<section>
{fields.map((field, index) => (
<ContentsContainer
key={field.id}
content={field as ItemsType & { id: string }}
order={index}
handleDeleteBlock={handleDeleteBlock}
/>
))}
</section>
<section className={styles.contents}>
{Object.entries(NOTICE_CONTENT).map(([key, value], index) => (
<button
key={index}
className={styles.block}
onClick={handleAddBlock(key as NoticeContentsType)}
type="button"
>{`+ ${value} 추가`}</button>
))}
</section>
</>
);
}
39 changes: 39 additions & 0 deletions src/app/admin/notice/create/_components/block/BodyContent.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<MDEditor
value={text}
onChange={handleChange}
textareaProps={{ placeholder: 'Please enter Markdown text' }}
height={200}
/>
<button onClick={addContentsBody} type="button" className={styles.contentButton}>
등록
</button>
<span className={styles.comment}>* 본문을 작성하는 경우 반드시 등록 버튼을 눌러 내용을 저장해주세요.</span>
</>
);
}
34 changes: 34 additions & 0 deletions src/app/admin/notice/create/_components/block/ButtonContent.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div>
<span className={styles.buttonTitle}>버튼명</span>
<input
className={styles.input}
placeholder="버튼명을 입력해주세요."
{...register(`contents.${order}.buttonName`)}
/>
</div>
<div>
<span className={styles.buttonTitle}>링크</span>
<input
className={styles.input}
placeholder="버튼에 연결할 링크를 입력해주세요."
{...register(`contents.${order}.buttonLink`)}
/>
<p></p>
</div>
</>
);
}
Loading