diff --git a/src/app/admin/blog/magnet/[id]/post/page.tsx b/src/app/admin/blog/magnet/[id]/post/page.tsx index 5b6925490..7d09a3b52 100644 --- a/src/app/admin/blog/magnet/[id]/post/page.tsx +++ b/src/app/admin/blog/magnet/[id]/post/page.tsx @@ -1,4 +1,11 @@ -// TODO: 마그넷 글 관리 페이지 구현 예정 -export default function MagnetPostPage() { - return
글 관리 (준비 중)
; -} +import { fetchMagnetPost } from '@/domain/admin/blog/magnet/mock'; +import MagnetPostPage from '@/domain/admin/blog/magnet/MagnetPostPage'; + +const Page = async ({ params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const initialData = await fetchMagnetPost(Number(id)); + + return ; +}; + +export default Page; diff --git a/src/domain/admin/blog/magnet/MagnetPostPage.tsx b/src/domain/admin/blog/magnet/MagnetPostPage.tsx new file mode 100644 index 000000000..412ef5da2 --- /dev/null +++ b/src/domain/admin/blog/magnet/MagnetPostPage.tsx @@ -0,0 +1,173 @@ +'use client'; + +import TextFieldLimit from '@/domain/admin/blog/TextFieldLimit'; +import { useMagnetPostForm } from '@/domain/admin/blog/magnet/hooks/useMagnetPostForm'; +import MagnetProgramRecommendSection from '@/domain/admin/blog/magnet/section/MagnetProgramRecommendSection'; +import MagnetRecommendSection from '@/domain/admin/blog/magnet/section/MagnetRecommendSection'; +import { MAGNET_TYPE, MagnetPostDetail } from '@/domain/admin/blog/magnet/types'; +import Heading from '@/domain/admin/ui/heading/Heading'; +import Heading2 from '@/domain/admin/ui/heading/Heading2'; +import ImageUpload from '@/domain/admin/program/ui/form/ImageUpload'; +import { Button, Checkbox, FormControlLabel } from '@mui/material'; +import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; +import dynamic from 'next/dynamic'; + +const EditorApp = dynamic( + () => import('@/domain/admin/lexical/EditorApp'), + { ssr: false }, +); + +const MAX_META_DESCRIPTION_LENGTH = 100; + +interface MagnetPostPageProps { + magnetId: string; + initialData: MagnetPostDetail; +} + +const MagnetPostPage = ({ magnetId, initialData }: MagnetPostPageProps) => { + const { + type, + title, + formState, + displayDate, + endDate, + content, + initialEditorStateBefore, + initialEditorStateAfter, + onChangeMetaDescription, + onChangeThumbnailFile, + onChangeHasCommonForm, + onChangeProgramRecommend, + onChangeMagnetRecommend, + onChangeEditorBefore, + onChangeEditorAfter, + setDisplayDate, + setEndDate, + savePost, + navigateToList, + } = useMagnetPostForm({ magnetId, initialData }); + + return ( +
+
+ 마그넷 글 관리 +
+
+
+ {/* 4.1 타입 */} +

+ 타입:  {MAGNET_TYPE[type]} +

+ + {/* 4.2 제목 */} +

+ 제목:  {title} +

+ + {/* 4.3 메타 디스크립션 */} + + + {/* 4.4 썸네일 */} +
+ +
+ + {/* 4.5 프로그램 추천 + 4.6 마그넷 추천 */} +
+ + +
+ + {/* 4.7 노출 기간 */} +
+ 노출 기간 +
+ + +
+
+ + {/* 4.8 공통 신청폼 추가 */} + onChangeHasCommonForm(e.target.checked)} + /> + } + label="공통 신청폼 추가" + /> + + {/* 4.9 콘텐츠 편집1 (신청 전 공개) */} +
+ 콘텐츠 편집1(신청 전 공개) + +
+ + {/* 4.10 콘텐츠 편집2 (신청 후 공개) */} +
+ 콘텐츠 편집2(신청 후 공개) + +
+ + {/* 4.11 액션 버튼 */} +
+ + +
+
+
+
+ ); +}; + +export default MagnetPostPage; diff --git a/src/domain/admin/blog/magnet/hooks/useMagnetPostForm.ts b/src/domain/admin/blog/magnet/hooks/useMagnetPostForm.ts new file mode 100644 index 000000000..edf133f36 --- /dev/null +++ b/src/domain/admin/blog/magnet/hooks/useMagnetPostForm.ts @@ -0,0 +1,153 @@ +import { uploadFile } from '@/api/file'; +import { saveMagnetPost } from '@/domain/admin/blog/magnet/mock'; +import { + MagnetPostContent, + MagnetPostDetail, + MagnetProgramRecommendItem, +} from '@/domain/admin/blog/magnet/types'; +import { useAdminSnackbar } from '@/hooks/useAdminSnackbar'; +import dayjs from '@/lib/dayjs'; +import { Dayjs } from 'dayjs'; +import { useRouter } from 'next/navigation'; +import { ChangeEvent, useMemo, useState } from 'react'; + +const RECOMMEND_SLOT_COUNT = 4; + +function createEmptyContent(): MagnetPostContent { + return { + programRecommend: Array.from({ length: RECOMMEND_SLOT_COUNT }, () => ({ + id: null, + })), + magnetRecommend: Array.from({ length: RECOMMEND_SLOT_COUNT }, () => null), + }; +} + +function parseInitialContent(data: MagnetPostDetail): MagnetPostContent { + if (!data.content || data.content === '') return createEmptyContent(); + try { + return JSON.parse(data.content); + } catch { + return createEmptyContent(); + } +} + +interface FormState { + metaDescription: string; + thumbnail: string; + hasCommonForm: boolean; +} + +function buildInitialFormState(data: MagnetPostDetail): FormState { + return { + metaDescription: data.metaDescription ?? '', + thumbnail: data.thumbnail ?? '', + hasCommonForm: data.hasCommonForm ?? false, + }; +} + +interface UseMagnetPostFormParams { + magnetId: string; + initialData: MagnetPostDetail; +} + +export const useMagnetPostForm = ({ + magnetId, + initialData, +}: UseMagnetPostFormParams) => { + const router = useRouter(); + const { snackbar: setSnackbar } = useAdminSnackbar(); + + const initialContent = useMemo( + () => parseInitialContent(initialData), + [initialData], + ); + const initialFormState = useMemo( + () => buildInitialFormState(initialData), + [initialData], + ); + + const [formState, setFormState] = useState(initialFormState); + const [displayDate, setDisplayDate] = useState( + initialData.displayDate ? dayjs(initialData.displayDate) : null, + ); + const [endDate, setEndDate] = useState( + initialData.endDate ? dayjs(initialData.endDate) : null, + ); + const [content, setContent] = useState(initialContent); + + const onChangeMetaDescription = (e: ChangeEvent) => { + setFormState((prev) => ({ ...prev, metaDescription: e.target.value })); + }; + + const onChangeThumbnailFile = async ( + e: ChangeEvent, + ) => { + const file = e.target.files?.item(0); + if (!file) { + setSnackbar('파일이 없습니다.'); + return; + } + const url = await uploadFile({ file, type: 'BLOG' }); + setFormState((prev) => ({ ...prev, thumbnail: url })); + }; + + const onChangeHasCommonForm = (checked: boolean) => { + setFormState((prev) => ({ ...prev, hasCommonForm: checked })); + }; + + const onChangeProgramRecommend = (items: MagnetProgramRecommendItem[]) => { + setContent((prev) => ({ ...prev, programRecommend: items })); + }; + + const onChangeMagnetRecommend = (items: (number | null)[]) => { + setContent((prev) => ({ ...prev, magnetRecommend: items })); + }; + + const onChangeEditorBefore = (jsonString: string) => { + setContent((prev) => ({ ...prev, lexicalBefore: jsonString })); + }; + + const onChangeEditorAfter = (jsonString: string) => { + setContent((prev) => ({ ...prev, lexicalAfter: jsonString })); + }; + + const savePost = async () => { + await saveMagnetPost({ + magnetId: Number(magnetId), + metaDescription: formState.metaDescription, + thumbnail: formState.thumbnail, + displayDate: displayDate?.format('YYYY-MM-DDTHH:mm') ?? null, + endDate: endDate?.format('YYYY-MM-DDTHH:mm') ?? null, + hasCommonForm: formState.hasCommonForm, + content: JSON.stringify(content), + isVisible: false, + }); + setSnackbar('마그넷 글이 저장되었습니다.'); + }; + + const navigateToList = () => { + router.push('/admin/blog/magnet/list'); + }; + + return { + type: initialData.type, + title: initialData.title, + formState, + displayDate, + endDate, + content, + initialEditorStateBefore: initialContent.lexicalBefore, + initialEditorStateAfter: initialContent.lexicalAfter, + onChangeMetaDescription, + onChangeThumbnailFile, + onChangeHasCommonForm, + onChangeProgramRecommend, + onChangeMagnetRecommend, + onChangeEditorBefore, + onChangeEditorAfter, + setDisplayDate, + setEndDate, + savePost, + navigateToList, + }; +}; diff --git a/src/domain/admin/blog/magnet/mock.ts b/src/domain/admin/blog/magnet/mock.ts index bd70823fe..016215604 100644 --- a/src/domain/admin/blog/magnet/mock.ts +++ b/src/domain/admin/blog/magnet/mock.ts @@ -1,6 +1,12 @@ // TODO: API 준비 후 src/api/magnet/ 폴더로 이동하고 실제 API 호출로 교체 import { IPageInfo } from '@/types/interface'; -import { MagnetListItem, MagnetTypeKey } from './types'; +import { + MANAGEABLE_MAGNET_TYPES, + MagnetListItem, + MagnetPostDetail, + MagnetPostReqBody, + MagnetTypeKey, +} from './types'; const MOCK_MAGNETS: MagnetListItem[] = [ { @@ -153,3 +159,54 @@ export function deleteMagnet(id: number): void { const index = MOCK_MAGNETS.findIndex((m) => m.id === id); if (index !== -1) MOCK_MAGNETS.splice(index, 1); } + +// --- 마그넷 글 관리 (포스트) --- + +const MOCK_MAGNET_POSTS: Record = {}; + +function buildDefaultPost(magnetId: number): MagnetPostDetail { + const magnet = MOCK_MAGNETS.find((m) => m.id === magnetId); + return { + magnetId, + type: magnet?.type ?? 'RESOURCE', + title: magnet?.title ?? '', + metaDescription: '', + thumbnail: '', + displayDate: magnet?.displayDate ?? null, + endDate: magnet?.endDate ?? null, + hasCommonForm: false, + content: '', + isVisible: false, + }; +} + +// TODO: API 준비 후 server-side fetch로 교체 +export async function fetchMagnetPost( + magnetId: number, +): Promise { + return MOCK_MAGNET_POSTS[magnetId] ?? buildDefaultPost(magnetId); +} + +// TODO: API 준비 후 useSaveMagnetPostMutation React Query 훅으로 교체 +export function saveMagnetPost(body: MagnetPostReqBody): void { + const magnet = MOCK_MAGNETS.find((m) => m.id === body.magnetId); + MOCK_MAGNET_POSTS[body.magnetId] = { + magnetId: body.magnetId, + type: magnet?.type ?? 'RESOURCE', + title: magnet?.title ?? '', + metaDescription: body.metaDescription, + thumbnail: body.thumbnail, + displayDate: body.displayDate, + endDate: body.endDate, + hasCommonForm: body.hasCommonForm, + content: body.content, + isVisible: body.isVisible, + }; +} + +// TODO: API 준비 후 React Query 훅으로 교체 +export function fetchManageableMagnets(): MagnetListItem[] { + return MOCK_MAGNETS.filter((m) => + MANAGEABLE_MAGNET_TYPES.includes(m.type), + ); +} diff --git a/src/domain/admin/blog/magnet/section/MagnetProgramRecommendSection.tsx b/src/domain/admin/blog/magnet/section/MagnetProgramRecommendSection.tsx new file mode 100644 index 000000000..66bb18cc0 --- /dev/null +++ b/src/domain/admin/blog/magnet/section/MagnetProgramRecommendSection.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useGetProgramAdminQuery } from '@/api/program'; +import { MagnetProgramRecommendItem } from '@/domain/admin/blog/magnet/types'; +import Heading2 from '@/domain/admin/ui/heading/Heading2'; +import { ProgramStatusEnum } from '@/schema'; +import { programStatusToText } from '@/utils/convert'; +import { + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, + TextField, +} from '@mui/material'; +import { ChangeEvent, useMemo } from 'react'; + +const { PROCEEDING, PREV } = ProgramStatusEnum.enum; +const MAX_PROGRAM_FETCH_SIZE = 10000; + +// TODO: 백엔드에서 challengeType 필드가 admin program API에 추가되면 제목 추론 대신 직접 사용 +const CTA_SUBTITLE_MAP: Record = { + 경험정리: '취업 서류 준비의 기초·필수 코어가 될', + 이력서: '매력적인 이력서를 완성하는 1주일', + 자기소개서: '만능 답변으로 진짜 나를 드러내는', + 포트폴리오: '나의 경험을 200% 활용하여 제작하는', + 마케팅: '마케팅 현직자 멘토와 함께하는', + 대기업: '현직자와 함께 끝내는 공채준비', +}; + +function inferCtaSubtitle(title: string): string { + for (const [keyword, subtitle] of Object.entries(CTA_SUBTITLE_MAP)) { + if (title.includes(keyword)) return subtitle; + } + return ''; +} + +interface MagnetProgramRecommendSectionProps { + programRecommend: MagnetProgramRecommendItem[]; + onChangeProgramRecommend: (items: MagnetProgramRecommendItem[]) => void; +} + +const MagnetProgramRecommendSection = ({ + programRecommend, + onChangeProgramRecommend, +}: MagnetProgramRecommendSectionProps) => { + const { data } = useGetProgramAdminQuery({ + page: 1, + size: MAX_PROGRAM_FETCH_SIZE, + type: 'CHALLENGE', + status: [PROCEEDING, PREV], + }); + + const { menuItems, titleByValue } = useMemo(() => { + const items: React.JSX.Element[] = [ + + 선택 안 함 + , + ]; + const titleMap = new Map(); + + data?.programList + .filter( + (p) => + p.programInfo.isVisible && + (p.programInfo.title ?? '').endsWith('챌린지'), + ) + .forEach((p) => { + const value = `CHALLENGE-${p.programInfo.id}`; + const title = p.programInfo.title ?? ''; + titleMap.set(value, title); + items.push( + + {`[챌린지/${programStatusToText[p.programInfo.programStatusType]}] ${title}`} + , + ); + }); + + return { menuItems: items, titleByValue: titleMap }; + }, [data]); + + const handleChange = ( + e: + | SelectChangeEvent + | ChangeEvent, + index: number, + ) => { + const list = [...programRecommend]; + const item = { ...list[index], [e.target.name]: e.target.value }; + const notSelectProgram = e.target.value === 'null'; + + if (e.target.name === 'id' && notSelectProgram) { + item.id = null; + delete item.ctaLink; + delete item.ctaTitle; + } + + if (e.target.name === 'id' && !notSelectProgram) { + const title = titleByValue.get(e.target.value as string) ?? ''; + item.ctaTitle = inferCtaSubtitle(title); + } + + onChangeProgramRecommend([ + ...list.slice(0, index), + item, + ...list.slice(index + 1), + ]); + }; + + return ( +
+
+ 프로그램 추천 + + *노출된 프로그램 중 모집중, 모집예정인 프로그램만 불러옵니다. + +
+
+ {programRecommend.map((item, index) => ( +
+ + 프로그램 선택 + + + handleChange(e, index)} + /> + {!item.id && ( + handleChange(e, index)} + /> + )} +
+ ))} + + { + "*CTA링크: 'latest:{text}'으로 설정하면, text를 제목에 포함하는 챌린지 상세페이지로 이동합니다. (예시) latest:인턴" + } + +
+
+ ); +}; + +export default MagnetProgramRecommendSection; diff --git a/src/domain/admin/blog/magnet/section/MagnetRecommendSection.tsx b/src/domain/admin/blog/magnet/section/MagnetRecommendSection.tsx new file mode 100644 index 000000000..e811c287c --- /dev/null +++ b/src/domain/admin/blog/magnet/section/MagnetRecommendSection.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { fetchManageableMagnets } from '@/domain/admin/blog/magnet/mock'; +import { MAGNET_TYPE } from '@/domain/admin/blog/magnet/types'; +import Heading2 from '@/domain/admin/ui/heading/Heading2'; +import { + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, +} from '@mui/material'; +import { useMemo } from 'react'; + +interface MagnetRecommendSectionProps { + magnetRecommend: (number | null)[]; + onChangeMagnetRecommend: (items: (number | null)[]) => void; +} + +const MagnetRecommendSection = ({ + magnetRecommend, + onChangeMagnetRecommend, +}: MagnetRecommendSectionProps) => { + // TODO: API 준비 후 React Query 훅으로 교체 + const magnetMenuItems = useMemo( + () => [ + + 선택 안 함 + , + ...fetchManageableMagnets().map((m) => ( + + {`[${m.id}] ${MAGNET_TYPE[m.type]} - ${m.title}`} + + )), + ], + [], + ); + + const handleChange = ( + e: SelectChangeEvent, + index: number, + ) => { + const list = [...magnetRecommend]; + const value = e.target.value; + list[index] = value === 'null' ? null : Number(value); + onChangeMagnetRecommend(list); + }; + + return ( +
+ 마그넷 추천 +
+ {magnetRecommend.map((id, index) => ( + + 자료집 ID {index + 1} + + + ))} +
+
+ ); +}; + +export default MagnetRecommendSection; diff --git a/src/domain/admin/blog/magnet/types.ts b/src/domain/admin/blog/magnet/types.ts index e14f7c8db..4f67a3787 100644 --- a/src/domain/admin/blog/magnet/types.ts +++ b/src/domain/admin/blog/magnet/types.ts @@ -45,3 +45,46 @@ export interface MagnetFilterValues { type: string; titleKeyword: string; } + +// --- 마그넷 글 관리 (포스트) --- + +/** 프로그램 추천 슬롯 */ +export interface MagnetProgramRecommendItem { + id: string | null; + ctaTitle?: string; + ctaLink?: string; +} + +/** 마그넷 콘텐츠 (JSON으로 직렬화하여 저장) */ +export interface MagnetPostContent { + programRecommend: MagnetProgramRecommendItem[]; + magnetRecommend: (number | null)[]; + lexicalBefore?: string; + lexicalAfter?: string; +} + +/** 마그넷 포스트 상세 (단건 조회) */ +export interface MagnetPostDetail { + magnetId: number; + type: MagnetTypeKey; + title: string; + metaDescription: string; + thumbnail: string; + displayDate: string | null; + endDate: string | null; + hasCommonForm: boolean; + content: string; + isVisible: boolean; +} + +/** 마그넷 포스트 저장 요청 */ +export interface MagnetPostReqBody { + magnetId: number; + metaDescription: string; + thumbnail: string; + displayDate: string | null; + endDate: string | null; + hasCommonForm: boolean; + content: string; + isVisible: boolean; +}