From 28d4b8ed1a4f7b7d9b12f85e5fd1cc5bb7675341 Mon Sep 17 00:00:00 2001 From: goodsmell <87801306+goodsmell@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:34:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=A7=89=EA=B3=BC=20=EC=9E=A5=20/?= =?UTF-8?q?=20=EB=AC=B4=EB=8C=80=20=EC=A0=95=EB=B3=B4=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/image/myPage/pupple_dot_line.svg | 3 + src/assets/image/myPage/pupple_line.svg | 3 - src/mocks/handlers.ts | 504 +++++++++++++++++- .../myPage/PostManage/PostManageDetail.tsx | 148 +---- .../myPage/PostManage/PostMangeDetail.scss | 54 +- src/pages/work/Detail.tsx | 2 - 6 files changed, 516 insertions(+), 198 deletions(-) create mode 100644 src/assets/image/myPage/pupple_dot_line.svg delete mode 100644 src/assets/image/myPage/pupple_line.svg diff --git a/src/assets/image/myPage/pupple_dot_line.svg b/src/assets/image/myPage/pupple_dot_line.svg new file mode 100644 index 00000000..d35d7f84 --- /dev/null +++ b/src/assets/image/myPage/pupple_dot_line.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/image/myPage/pupple_line.svg b/src/assets/image/myPage/pupple_line.svg deleted file mode 100644 index a7c1e71f..00000000 --- a/src/assets/image/myPage/pupple_line.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index a179034b..03958e90 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,5 +1,6 @@ import { delay, http, HttpResponse } from "msw"; import type { ScriptItem } from "@/api/user/postListApi"; +import type { Review, ReviewStatistics } from "@/types/review"; const BASE = "http://localhost:8080"; @@ -603,6 +604,313 @@ const sortList = (list: ScriptItem[], sortType: string) => const getLikedLongPlays = () => longPlays.filter((script) => script.like); const getLikedShortPlays = () => shortPlays.filter((script) => script.like); +type WorkReviewStatus = "WAIT" | "PASS" | "RE_WAIT" | "RE_PASS"; + +interface ManagedWork { + id: string; + title: string; + writer: string; + imagePath: string; + script: boolean; + scriptPrice: number; + performance: boolean; + performancePrice: number; + checked: WorkReviewStatus; + createdDate: string; + descriptionPath: string; + playType: "SHORT" | "LONG"; + plot: string; + intention: string; + buyStatus: number; + any: number; + male: number; + female: number; + stageComment: string; + runningTime: number; + scene: number; + act: number; +} + +// 작품 관리 화면에서 심사/판매 상태별 UI를 한 번에 확인하기 위한 개발용 데이터입니다. +let managedWorks: ManagedWork[] = [ + { + id: "10000000-0000-4000-8000-000000000001", + title: "유리 온실의 밤", + writer: "테스트작가", + imagePath: makePlaceholderImage(101), + script: true, + scriptPrice: 5000, + performance: true, + performancePrice: 30000, + checked: "PASS", + createdDate: "2026-09-18", + descriptionPath: "/test.pdf", + playType: "LONG", + plot: "폐쇄를 앞둔 식물원에서 세 사람이 마지막 밤을 보내며 감춰 둔 진실을 마주한다.", + intention: "떠나보내는 과정에서 비로소 선명해지는 관계를 무대 위에 담고자 했습니다.", + buyStatus: 0, + any: 1, + male: 1, + female: 2, + stageComment: "온실을 연상시키는 투명한 소품과 푸른 조명을 권장합니다.", + runningTime: 90, + scene: 6, + act: 2, + }, + { + id: "10000000-0000-4000-8000-000000000002", + title: "막차는 오지 않는다", + writer: "테스트작가", + imagePath: makePlaceholderImage(102), + script: true, + scriptPrice: 3500, + performance: false, + performancePrice: 0, + checked: "PASS", + createdDate: "2026-09-18", + descriptionPath: "/test.pdf", + playType: "SHORT", + plot: "막차가 끊긴 시골 정류장에서 낯선 두 사람이 서로의 목적지를 묻는다.", + intention: "기다림이라는 짧은 시간 안에 생기는 연대와 위로를 이야기합니다.", + buyStatus: 0, + any: 0, + male: 1, + female: 1, + stageComment: "벤치 하나만으로 공연할 수 있는 소극장용 작품입니다.", + runningTime: 35, + scene: 3, + act: 1, + }, + { + id: "10000000-0000-4000-8000-000000000003", + title: "파도에게 보내는 편지", + writer: "테스트작가", + imagePath: makePlaceholderImage(103), + script: false, + scriptPrice: 4500, + performance: true, + performancePrice: 18000, + checked: "RE_WAIT", + createdDate: "2026-09-12", + descriptionPath: "/test.pdf", + playType: "LONG", + plot: "섬마을 우체국에 배달되지 못한 편지들이 쌓이면서 오래된 사건이 다시 떠오른다.", + intention: "전하지 못한 말이 사람과 공동체에 남기는 흔적을 그리고 싶었습니다.", + buyStatus: 0, + any: 1, + male: 2, + female: 3, + stageComment: "파도 소리를 장면 전환의 중심 요소로 사용합니다.", + runningTime: 105, + scene: 8, + act: 2, + }, + { + id: "10000000-0000-4000-8000-000000000004", + title: "오늘의 분실물", + writer: "테스트작가", + imagePath: makePlaceholderImage(104), + script: false, + scriptPrice: 0, + performance: false, + performancePrice: 0, + checked: "WAIT", + createdDate: "2026-09-05", + descriptionPath: "/test.pdf", + playType: "SHORT", + plot: "분실물 보관소 직원이 주인 없는 물건들의 하루를 상상하며 기록한다.", + intention: "사라진 물건을 통해 평범한 하루의 소중함을 바라봅니다.", + buyStatus: 0, + any: 1, + male: 1, + female: 1, + stageComment: "여러 개의 상자와 작은 테이블이 필요합니다.", + runningTime: 45, + scene: 5, + act: 1, + }, + { + id: "10000000-0000-4000-8000-000000000005", + title: "별이 내리는 세탁소", + writer: "테스트작가", + imagePath: makePlaceholderImage(105), + script: true, + scriptPrice: 6000, + performance: true, + performancePrice: 25000, + checked: "RE_PASS", + createdDate: "2026-08-27", + descriptionPath: "/test.pdf", + playType: "LONG", + plot: "밤에만 문을 여는 세탁소에서 손님들은 옷과 함께 잊고 싶은 기억을 맡긴다.", + intention: "기억을 지우는 대신 잘 개어 간직하는 방법에 관한 이야기입니다.", + buyStatus: 0, + any: 2, + male: 2, + female: 2, + stageComment: "회전하는 세탁기 조명을 별빛처럼 활용할 수 있습니다.", + runningTime: 80, + scene: 7, + act: 2, + }, +]; + +const toManagedWorkList = () => { + const groups = new Map(); + managedWorks.forEach((work) => { + groups.set(work.createdDate, [...(groups.get(work.createdDate) ?? []), work]); + }); + + return [...groups.entries()] + .sort(([dateA], [dateB]) => dateB.localeCompare(dateA)) + .map(([date, works]) => ({ + date, + works: works.map(({ createdDate: _createdDate, ...work }) => work), + })); +}; + +const detailReviews: Review[] = [ + { + id: "20000000-0000-4000-8000-000000000001", + nickname: "포도독자", + stageType: "GRAPE_CLUSTER", + date: "2026-09-17T14:20:00", + isEdited: false, + myself: false, + rating: 5, + standardType: "STORY", + content: + "장면이 바뀔 때마다 앞에서 지나쳤던 대사가 새롭게 들렸어요. 마지막 장면까지 긴장감이 잘 이어지는 작품입니다.", + isLike: true, + likeCount: 18, + }, + { + id: "20000000-0000-4000-8000-000000000002", + nickname: "무대위고양이", + stageType: "WINE", + date: "2026-09-15T09:10:00", + isEdited: true, + myself: false, + rating: 4, + standardType: "CHARACTER", + content: + "등장인물의 선택에 충분한 이유가 있어서 배우들과 인물을 분석하기 좋았습니다. 소극장 공연으로도 잘 어울릴 것 같아요.", + isLike: false, + likeCount: 11, + }, + { + id: "20000000-0000-4000-8000-000000000003", + nickname: "오늘도연습", + stageType: "SINGLE_GRAPE", + date: "2026-09-12T18:45:00", + isEdited: false, + myself: false, + rating: 5, + standardType: "RELATION", + content: "인물 사이의 거리감이 대사와 동선으로 섬세하게 표현되어 있어 인상 깊었습니다.", + isLike: false, + likeCount: 7, + }, + { + id: "20000000-0000-4000-8000-000000000004", + nickname: "극장앞자리", + stageType: "DEFAULT", + date: "2026-09-10T11:30:00", + isEdited: false, + myself: false, + rating: 3, + standardType: "STORY", + content: "중반부 전개는 조금 느렸지만 결말이 좋았고 읽고 난 뒤에도 여운이 남았습니다.", + isLike: false, + likeCount: 3, + }, + { + id: "20000000-0000-4000-8000-000000000005", + nickname: "보라빛무대", + stageType: "GRAPE_CLUSTER", + date: "2026-09-08T20:05:00", + isEdited: false, + myself: false, + rating: 5, + standardType: "CHARACTER", + content: "각 인물의 말투가 뚜렷해서 대본만 읽어도 장면이 자연스럽게 그려졌습니다.", + isLike: true, + likeCount: 14, + }, + { + id: "20000000-0000-4000-8000-000000000006", + nickname: "대학로산책", + stageType: "SINGLE_GRAPE", + date: "2026-09-05T16:40:00", + isEdited: false, + myself: false, + rating: 4, + standardType: "RELATION", + content: "적은 인원으로도 관계의 변화를 풍성하게 보여줄 수 있는 작품이라 좋았습니다.", + isLike: false, + likeCount: 5, + }, +]; + +const toPercent = (count: number, total: number) => + total === 0 ? 0 : Math.round((count / total) * 1000) / 10; + +const makeReviewStatistics = (reviews: Review[]): ReviewStatistics => { + const total = reviews.length; + const countRating = (rating: number) => + reviews.filter((review) => review.rating === rating).length; + const countStandard = (standard: string) => + reviews.filter((review) => review.standardType === standard).length; + + return { + totalReviewCount: total, + reviewAverageRating: + total === 0 + ? 0 + : Math.round((reviews.reduce((sum, review) => sum + review.rating, 0) / total) * 10) / 10, + fiveStarPercent: toPercent(countRating(5), total), + fourStarPercent: toPercent(countRating(4), total), + threeStarPercent: toPercent(countRating(3), total), + twoStarPercent: toPercent(countRating(2), total), + oneStarPercent: toPercent(countRating(1), total), + characterPercent: toPercent(countStandard("CHARACTER"), total), + relationPercent: toPercent(countStandard("RELATION"), total), + storyPercent: toPercent(countStandard("STORY"), total), + }; +}; + +const detailEngagement = new Map(); + +const findDetailWork = (id: string) => { + const managed = managedWorks.find((work) => work.id === id); + if (managed) return managed; + + const listed = [...longPlays, ...shortPlays].find((work) => work.id === id); + if (!listed) return undefined; + + return { + ...listed, + createdDate: listed.date.slice(0, 10), + descriptionPath: "/test.pdf", + playType: longPlays.includes(listed) ? ("LONG" as const) : ("SHORT" as const), + plot: "서로 다른 비밀을 품은 인물들이 한 공간에 모이면서 예상하지 못한 진실과 마주한다.", + intention: "관계 속에서 쉽게 말하지 못했던 마음과 선택의 순간을 담아낸 작품입니다.", + buyStatus: 0, + any: 1, + male: 2, + female: 2, + stageComment: "간결한 무대와 장면의 분위기를 살리는 조명 연출을 권장합니다.", + runningTime: longPlays.includes(listed) ? 90 : 40, + scene: longPlays.includes(listed) ? 7 : 4, + act: longPlays.includes(listed) ? 2 : 1, + }; +}; + +const getPdfResponse = async () => { + const pdf = await fetch(new URL("/test.pdf", globalThis.location.origin)); + return pdf.arrayBuffer(); +}; + export const handlers = [ // 로그인 (조건 없이 항상 성공) http.post(`${BASE}/auth/signin`, async ({ request }) => { @@ -623,6 +931,166 @@ export const handlers = [ }); }), + // 마이페이지 > 작품 관리 목록 + http.get(`${BASE}/profile/work`, async () => { + await delay(250); + return HttpResponse.json({ + nickname: "테스트작가", + dateWorks: toManagedWorkList(), + }); + }), + + // 작품 관리 상세 + http.get(`${BASE}/profile/work/detail`, ({ request }) => { + const id = new URL(request.url).searchParams.get("script"); + const work = managedWorks.find((item) => item.id === id); + if (!work) return HttpResponse.json({ error: "상품을 찾을 수 없습니다." }, { status: 404 }); + + const { createdDate, ...detail } = work; + return HttpResponse.json({ ...detail, date: `${createdDate}T12:00:00` }); + }), + + // 상세 수정, 삭제, 심사 취소 및 파일 변경 플로우 + http.post(`${BASE}/profile/work/detail`, async ({ request }) => { + const form = await request.formData(); + const id = String(form.get("id") ?? ""); + const index = managedWorks.findIndex((item) => item.id === id); + if (index < 0) return HttpResponse.json({ error: "상품을 찾을 수 없습니다." }, { status: 404 }); + + const current = managedWorks[index]; + managedWorks[index] = { + ...current, + title: String(form.get("title") ?? current.title), + plot: String(form.get("plot") ?? current.plot), + intention: String(form.get("intention") ?? current.intention), + script: form.get("script") === "true", + performance: form.get("performance") === "true", + scriptPrice: Number(form.get("scriptPrice") ?? current.scriptPrice), + performancePrice: Number(form.get("performancePrice") ?? current.performancePrice), + any: Number(form.get("any") ?? current.any), + male: Number(form.get("male") ?? current.male), + female: Number(form.get("female") ?? current.female), + stageComment: String(form.get("stageComment") ?? current.stageComment), + runningTime: Number(form.get("runningTime") ?? current.runningTime), + scene: Number(form.get("scene") ?? current.scene), + act: Number(form.get("act") ?? current.act), + }; + return HttpResponse.json(true); + }), + + http.delete(`${BASE}/profile/work/deleteScript/:id`, ({ params }) => { + const exists = managedWorks.some((work) => work.id === params.id); + if (!exists) return HttpResponse.json({ error: "상품을 찾을 수 없습니다." }, { status: 404 }); + managedWorks = managedWorks.filter((work) => work.id !== params.id); + return HttpResponse.json(true); + }), + + http.delete(`${BASE}/profile/work/cancel/:id`, ({ params }) => { + const exists = managedWorks.some((work) => work.id === params.id); + if (!exists) return HttpResponse.json({ error: "상품을 찾을 수 없습니다." }, { status: 404 }); + managedWorks = managedWorks.filter((work) => work.id !== params.id); + return HttpResponse.json(true); + }), + + http.post(`${BASE}/profile/work/changeScript`, async ({ request }) => { + const form = await request.formData(); + const id = String(form.get("id") ?? ""); + const work = managedWorks.find((item) => item.id === id); + if (!work) return HttpResponse.json({ error: "상품을 찾을 수 없습니다." }, { status: 404 }); + work.checked = "RE_WAIT"; + return HttpResponse.json(true); + }), + + // 작품 상세: 정보, 후기 정렬 및 페이지네이션 + http.get(`${BASE}/scripts/detail`, ({ request }) => { + const url = new URL(request.url); + const id = url.searchParams.get("script") ?? ""; + const page = Number(url.searchParams.get("page") ?? 0); + const sortType = url.searchParams.get("sortType") ?? "LIKE_COUNT"; + const work = findDetailWork(id); + + if (!work) return HttpResponse.json({ error: "상품을 찾을 수 없습니다." }, { status: 404 }); + + const listed = [...longPlays, ...shortPlays].find((item) => item.id === id); + const engagement = detailEngagement.get(id) ?? { + like: listed?.like ?? false, + likeCount: listed?.likeCount ?? 24, + viewCount: listed?.viewCount ?? 187, + }; + detailEngagement.set(id, engagement); + + const sortedReviews = [...detailReviews].sort((a, b) => + sortType === "LATEST" + ? new Date(b.date).getTime() - new Date(a.date).getTime() + : b.likeCount - a.likeCount + ); + const reviews = sortedReviews.slice(page * 5, (page + 1) * 5); + + return HttpResponse.json({ + id: work.id, + title: work.title, + writer: work.writer, + imagePath: work.imagePath, + script: work.script, + scriptPrice: work.scriptPrice, + performance: work.performance, + performancePrice: work.performancePrice, + playType: work.playType, + checked: work.checked, + plot: work.plot, + date: `${work.createdDate}T12:00:00`, + buyOptions: [ + ...(work.script ? ["SCRIPT"] : []), + ...(work.performance ? ["PERFORMANCE"] : []), + ], + like: engagement.like, + likeCount: engagement.likeCount, + viewCount: engagement.viewCount, + any: work.any, + male: work.male, + female: work.female, + stageComment: work.stageComment, + runningTime: work.runningTime, + scene: work.scene, + act: work.act, + intention: work.intention, + isReviewWritten: false, + isMine: managedWorks.some((managedWork) => managedWork.id === id), + reviewStatistics: makeReviewStatistics(detailReviews), + reviews, + }); + }), + + // 상세 화면에서 자동으로 불러오는 작품 설명 PDF와 대본 미리보기 + http.get(`${BASE}/scripts/description`, async () => { + const pdf = await getPdfResponse(); + return new HttpResponse(pdf, { + headers: { "Content-Type": "application/pdf" }, + }); + }), + + http.get(`${BASE}/scripts/preview`, async ({ request }) => { + const id = new URL(request.url).searchParams.get("script") ?? ""; + const work = findDetailWork(id); + if (!work) return HttpResponse.json({ error: "상품을 찾을 수 없습니다." }, { status: 404 }); + + const pdf = await getPdfResponse(); + return new HttpResponse(pdf, { + headers: { + "Content-Type": "application/pdf", + "X-Total-Pages": work.playType === "SHORT" ? "18" : "72", + }, + }); + }), + + http.post(`${BASE}/scripts/review/like/:id`, ({ params }) => { + const review = detailReviews.find((item) => item.id === params.id); + if (!review) return HttpResponse.json({ error: "후기를 찾을 수 없습니다." }, { status: 404 }); + review.isLike = !review.isLike; + review.likeCount += review.isLike ? 1 : -1; + return HttpResponse.json(true); + }), + // 좋아요한 작품 조회 http.get(`${BASE}/profile/like`, async () => { await delay(350); @@ -638,9 +1106,7 @@ export const handlers = [ const url = new URL(request.url); const page = Number(url.searchParams.get("page") ?? 0); const likedLongPlays = getLikedLongPlays(); - return HttpResponse.json( - likedLongPlays.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) - ); + return HttpResponse.json(likedLongPlays.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)); }), // 좋아요한 단편 작품 페이지네이션 @@ -649,9 +1115,7 @@ export const handlers = [ const url = new URL(request.url); const page = Number(url.searchParams.get("page") ?? 0); const likedShortPlays = getLikedShortPlays(); - return HttpResponse.json( - likedShortPlays.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) - ); + return HttpResponse.json(likedShortPlays.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)); }), // 전체 작품 조회 (페이지네이션, Spring Page 응답 형태) @@ -753,10 +1217,28 @@ export const handlers = [ // 좋아요 토글 http.post(`${BASE}/scripts/like/:id`, ({ params }) => { - const target = [...longPlays, ...shortPlays].find((s) => s.id === params.id); - if (!target) return new HttpResponse(null, { status: 404 }); - target.like = !target.like; - target.likeCount += target.like ? 1 : -1; - return HttpResponse.json({ message: target.like ? "like" : "cancel like" }); + const id = String(params.id); + const target = [...longPlays, ...shortPlays].find((script) => script.id === id); + + if (target) { + target.like = !target.like; + target.likeCount += target.like ? 1 : -1; + const engagement = detailEngagement.get(id); + if (engagement) { + engagement.like = target.like; + engagement.likeCount = target.likeCount; + } + return HttpResponse.json({ message: target.like ? "like" : "cancel like" }); + } + + if (!managedWorks.some((work) => work.id === id)) { + return HttpResponse.json({ error: "상품을 찾을 수 없습니다." }, { status: 404 }); + } + + const engagement = detailEngagement.get(id) ?? { like: false, likeCount: 24, viewCount: 187 }; + engagement.like = !engagement.like; + engagement.likeCount += engagement.like ? 1 : -1; + detailEngagement.set(id, engagement); + return HttpResponse.json({ message: engagement.like ? "like" : "cancel like" }); }), ]; diff --git a/src/pages/myPage/PostManage/PostManageDetail.tsx b/src/pages/myPage/PostManage/PostManageDetail.tsx index 113c84e9..6756a4cc 100644 --- a/src/pages/myPage/PostManage/PostManageDetail.tsx +++ b/src/pages/myPage/PostManage/PostManageDetail.tsx @@ -21,7 +21,7 @@ import useWindowDimensions from "@/hooks/useWindowDimensions"; import grayCheckIcon from "@/assets/image/myPage/ic_gray_check.svg"; import puppleCheckIcon from "@/assets/image/myPage/ic_pupple_check.svg"; import stickIcon from "@/assets/image/myPage/ic_stick.svg"; -import puppleLine from "@/assets/image/myPage/pupple_line.svg"; +import puppleLine from "@/assets/image/myPage/pupple_dot_line.svg"; import circleInfoBtn from "@/assets/image/button/circleInfoBtn.svg"; import Cookies from "js-cookie"; @@ -279,7 +279,7 @@ const PostManageDetail: React.FC = () => { isSmallMobile ? "p-small-bold mb-[8px]" : "p-medium-bold mb-[10px]" }`} > - 작품 정보 + *작품 정보

@@ -335,13 +335,13 @@ const PostManageDetail: React.FC = () => {
{/* --- 개요 --- */}
-

개요

+

+ *개요 +

{/* 등장인물 */} -
-
+
+
{" "} { alt="입력 체크" /> @@ -360,7 +360,7 @@ const PostManageDetail: React.FC = () => {
@@ -545,7 +545,7 @@ const PostManageDetail: React.FC = () => { alt="입력 체크" /> @@ -609,132 +609,6 @@ const PostManageDetail: React.FC = () => {
- {/* 무대 */} -
-
- {" "} - 입력 체크 - - 무대 - -
- 구분선 -