Skip to content

Commit 4e5fad3

Browse files
committed
feat: 공개 피드와 마이페이지 화면 API 연동
1 parent a795132 commit 4e5fad3

18 files changed

Lines changed: 828 additions & 97 deletions

File tree

src/app/art/new/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export default function ArtCreatePage() {
7979

8080
createdDate: date?.toISOString().split("T")[0],
8181

82+
isPublic,
83+
8284
imageIds: uploadedImages.map(image => image.imageId),
8385

8486
thumbnailIndex: 0,

src/app/mypage/activities/page.tsx

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,89 @@
11
"use client";
22

3+
import { Images } from "lucide-react";
4+
import Image from "next/image";
5+
import Link from "next/link";
6+
37
import Header from "@/components/common/Header";
8+
import { useExhibitions } from "@/hooks/useExhibitions";
49
import { useRequireAuth } from "@/hooks/useRequireAuth";
10+
import { normalizeImageUrl } from "@/utils/normalizeImageUrl";
11+
12+
function formatDate(date: string) {
13+
return date.replaceAll("-", ".");
14+
}
515

616
export default function MypageActivitiesPage() {
717
const { isAuthReady, isAuthenticated } = useRequireAuth();
18+
const query = useExhibitions("CONFIRMED", 0, 20);
819

920
if (!isAuthReady || !isAuthenticated) return null;
1021

22+
const activities = query.data?.items ?? [];
23+
1124
return (
1225
<main className="bg-bg-primary min-h-dvh">
1326
<Header title="활동 정보" showBack />
1427

1528
<section className="px-5 py-6">
16-
<p className="text-body-2 text-text-secondary font-regular py-2">
17-
등록된 활동 정보가 없습니다.
18-
</p>
29+
{query.isLoading ? (
30+
<p className="text-body-2 text-text-secondary font-regular py-2">
31+
활동 정보를 불러오는 중입니다.
32+
</p>
33+
) : query.error ? (
34+
<div className="py-2">
35+
<p className="text-body-2 text-error-default">
36+
{query.error instanceof Error ? query.error.message : "활동 정보를 불러오지 못했습니다."}
37+
</p>
38+
<button
39+
type="button"
40+
onClick={() => void query.refetch()}
41+
className="border-border-primary text-body-2 text-text-primary mt-3 h-9 rounded-lg border px-4 font-medium"
42+
>
43+
다시 불러오기
44+
</button>
45+
</div>
46+
) : activities.length > 0 ? (
47+
<div className="flex flex-col gap-4">
48+
{activities.map((activity, index) => (
49+
<Link key={activity.id} href={`/exhibitions/${activity.id}`} className="block">
50+
<div className="flex items-center gap-4">
51+
<div className="relative size-18.5 shrink-0 overflow-hidden rounded-lg">
52+
{activity.thumbnailUrl ? (
53+
<Image
54+
src={normalizeImageUrl(activity.thumbnailUrl) ?? ""}
55+
alt=""
56+
fill
57+
sizes="74px"
58+
className="object-cover"
59+
/>
60+
) : (
61+
<div className="bg-bg-primary-darker text-text-disabled flex size-full items-center justify-center">
62+
<Images size={24} />
63+
</div>
64+
)}
65+
</div>
66+
67+
<div className="flex min-w-0 flex-1 flex-col gap-1 leading-[1.45] font-medium">
68+
<p className="text-body-1 text-text-primary truncate">{activity.title}</p>
69+
<div className="text-body-2 text-text-secondary flex flex-col gap-[3px]">
70+
<p>{`${formatDate(activity.startDate)}-${formatDate(activity.endDate)}`}</p>
71+
<p>{activity.spaceName}</p>
72+
</div>
73+
</div>
74+
</div>
75+
76+
{index < activities.length - 1 && (
77+
<div className="border-border-primary mt-4 border-t" />
78+
)}
79+
</Link>
80+
))}
81+
</div>
82+
) : (
83+
<p className="text-body-2 text-text-secondary font-regular py-2">
84+
등록된 활동 정보가 없습니다.
85+
</p>
86+
)}
1987
</section>
2088
</main>
2189
);

src/app/mypage/feed/page.tsx

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,71 @@
11
"use client";
22

33
import { useQuery } from "@tanstack/react-query";
4-
import { Plus } from "lucide-react";
4+
import { useInfiniteQuery } from "@tanstack/react-query";
5+
import { Images, LockKeyhole, Plus } from "lucide-react";
6+
import Image from "next/image";
7+
import Link from "next/link";
58
import { useRouter } from "next/navigation";
69

710
import Header from "@/components/common/Header";
811
import { useRequireAuth } from "@/hooks/useRequireAuth";
912
import { getMe } from "@/services/authApi";
13+
import { getMypageFeed } from "@/services/mypageApi";
14+
import type { MypageArtwork, MypageFeedApiItem, MypageFeedResponse } from "@/types/mypage";
15+
import { normalizeImageUrl } from "@/utils/normalizeImageUrl";
16+
17+
function toMypageFeedItem(item: MypageFeedApiItem): MypageArtwork {
18+
const basePath = item.targetType === "ARTWORK" ? "art" : "space";
19+
return {
20+
id: `${item.targetType.toLowerCase()}-${item.id}`,
21+
targetType: item.targetType,
22+
imageUrl: normalizeImageUrl(item.thumbnailUrl),
23+
title: item.title,
24+
type: item.type,
25+
statusLabel: item.isPublic ? "공개" : "비공개",
26+
isPrivate: !item.isPublic,
27+
href: `/${basePath}/${item.id}`,
28+
};
29+
}
30+
31+
function FeedCard({ item }: { item: MypageArtwork }) {
32+
return (
33+
<Link href={item.href} className="block min-w-0">
34+
<article className="flex h-55.5 min-w-0 flex-col">
35+
<div className="border-border-primary relative h-33.5 overflow-hidden rounded-lg border">
36+
{item.imageUrl ? (
37+
<Image src={item.imageUrl} alt="" fill sizes="168px" className="object-cover" />
38+
) : (
39+
<div className="bg-bg-primary-darker text-text-disabled flex size-full items-center justify-center">
40+
<Images size={28} />
41+
</div>
42+
)}
43+
44+
{item.isPrivate && (
45+
<>
46+
<div className="absolute inset-x-0 top-0 h-12 bg-gradient-to-b from-[rgba(26,26,30,0.5)] to-transparent" />
47+
<LockKeyhole
48+
size={20}
49+
className="text-text-invert absolute top-3 left-3"
50+
strokeWidth={2.2}
51+
/>
52+
</>
53+
)}
54+
</div>
55+
56+
<div className="flex w-full flex-col items-start gap-1 pt-2 pr-3.5 pb-3 pl-1">
57+
<span className="bg-object-primary-light text-text-primary-brand text-caption inline-flex h-5 items-center justify-center rounded px-1.5 py-0.5 font-medium">
58+
{item.statusLabel}
59+
</span>
60+
<div className="flex w-full flex-col gap-0.5">
61+
<p className="text-body-1 text-text-primary truncate font-semibold">{item.title}</p>
62+
<p className="text-label text-text-secondary font-regular truncate">{item.type}</p>
63+
</div>
64+
</div>
65+
</article>
66+
</Link>
67+
);
68+
}
1069

1170
export default function MypageFeedPage() {
1271
const router = useRouter();
@@ -17,9 +76,20 @@ export default function MypageFeedPage() {
1776
queryFn: ({ signal }) => getMe(signal),
1877
enabled: canFetchMe,
1978
});
79+
const feedQuery = useInfiniteQuery<MypageFeedResponse>({
80+
queryKey: ["mypage", "feed", 20],
81+
queryFn: ({ pageParam }) => getMypageFeed({ page: pageParam as number, size: 20 }),
82+
initialPageParam: 0,
83+
getNextPageParam: lastPage => (lastPage.hasNext ? lastPage.page + 1 : undefined),
84+
enabled: canFetchMe,
85+
});
2086

2187
if (!isAuthReady || !isAuthenticated) return null;
2288

89+
const feedItems = (feedQuery.data?.pages ?? [])
90+
.flatMap(page => page.items)
91+
.map(toMypageFeedItem);
92+
2393
const handleCreateClick = () => {
2494
if (meQuery.data?.role === "CREATOR") {
2595
router.push("/art/new");
@@ -53,7 +123,42 @@ export default function MypageFeedPage() {
53123
/>
54124

55125
<section className="px-5 py-6">
56-
<p className="text-body-2 text-text-secondary font-regular py-2">등록된 피드가 없습니다.</p>
126+
{feedQuery.isLoading ? (
127+
<p className="text-body-2 text-text-secondary font-regular py-2">피드를 불러오는 중입니다.</p>
128+
) : feedQuery.error ? (
129+
<div className="py-2">
130+
<p className="text-body-2 text-error-default">
131+
{feedQuery.error instanceof Error ? feedQuery.error.message : "피드를 불러오지 못했습니다."}
132+
</p>
133+
<button
134+
type="button"
135+
onClick={() => void feedQuery.refetch()}
136+
className="border-border-primary text-body-2 text-text-primary mt-3 h-9 rounded-lg border px-4 font-medium"
137+
>
138+
다시 불러오기
139+
</button>
140+
</div>
141+
) : feedItems.length > 0 ? (
142+
<>
143+
<div className="grid grid-cols-2 gap-x-3.5 gap-y-3.5">
144+
{feedItems.map(item => (
145+
<FeedCard key={item.id} item={item} />
146+
))}
147+
</div>
148+
{feedQuery.hasNextPage && (
149+
<button
150+
type="button"
151+
onClick={() => void feedQuery.fetchNextPage()}
152+
disabled={feedQuery.isFetchingNextPage}
153+
className="border-border-primary text-body-1 text-text-primary mt-6 h-11 w-full rounded-lg border font-medium disabled:opacity-50"
154+
>
155+
{feedQuery.isFetchingNextPage ? "불러오는 중" : "더보기"}
156+
</button>
157+
)}
158+
</>
159+
) : (
160+
<p className="text-body-2 text-text-secondary font-regular py-2">등록된 피드가 없습니다.</p>
161+
)}
57162
</section>
58163
</main>
59164
);

src/app/mypage/page.tsx

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@ import { ChevronRight, Images, LockKeyhole } from "lucide-react";
77
import Image from "next/image";
88
import Link from "next/link";
99

10+
import { useExhibitions } from "@/hooks/useExhibitions";
1011
import { useRequireAuth } from "@/hooks/useRequireAuth";
1112
import { getMe } from "@/services/authApi";
13+
import { getMypageFeed } from "@/services/mypageApi";
1214
import { getNicknamePolicy } from "@/services/userApi";
13-
import type { MypageArtwork, MypageData, MypageExhibition, MypageProfile } from "@/types/mypage";
15+
import type {
16+
MypageArtwork,
17+
MypageExhibition,
18+
MypageFeedApiItem,
19+
MypageProfile,
20+
} from "@/types/mypage";
1421
import { normalizeImageUrl } from "@/utils/normalizeImageUrl";
1522

1623
interface BadgeProps {
@@ -32,14 +39,6 @@ interface ArtworkCardProps {
3239
artwork: MypageArtwork;
3340
}
3441

35-
// 현재 백엔드 명세로 연결 가능한 프로필 정보만 API에서 채우고,
36-
// 활동 정보와 피드는 더미 데이터 없이 빈 상태로 노출합니다.
37-
const EMPTY_MYPAGE_DATA: MypageData = {
38-
profile: null,
39-
exhibitions: [],
40-
artworks: [],
41-
};
42-
4342
const ROLE_LABEL: Record<string, string> = {
4443
CREATOR: "크리에이터",
4544
SPACE_PARTNER: "공간 파트너",
@@ -50,6 +49,28 @@ const getRoleLabel = (role?: string) => {
5049
return ROLE_LABEL[role] ?? role;
5150
};
5251

52+
function formatDate(date: string) {
53+
return date.replaceAll("-", ".");
54+
}
55+
56+
function formatPeriod(startDate: string, endDate: string) {
57+
return `${formatDate(startDate)}-${formatDate(endDate)}`;
58+
}
59+
60+
function toMypageFeedItem(item: MypageFeedApiItem): MypageArtwork {
61+
const basePath = item.targetType === "ARTWORK" ? "art" : "space";
62+
return {
63+
id: `${item.targetType.toLowerCase()}-${item.id}`,
64+
targetType: item.targetType,
65+
imageUrl: normalizeImageUrl(item.thumbnailUrl),
66+
title: item.title,
67+
type: item.type,
68+
statusLabel: item.isPublic ? "공개" : "비공개",
69+
isPrivate: !item.isPublic,
70+
href: `/${basePath}/${item.id}`,
71+
};
72+
}
73+
5374
function Badge({ children, size = "medium" }: BadgeProps) {
5475
return (
5576
<span
@@ -182,7 +203,8 @@ function ExhibitionItem({ exhibition, hasDivider = false }: ExhibitionItemProps)
182203

183204
function ArtworkCard({ artwork }: ArtworkCardProps) {
184205
return (
185-
<article className="flex h-55.5 min-w-0 flex-col">
206+
<Link href={artwork.href} className="block min-w-0">
207+
<article className="flex h-55.5 min-w-0 flex-col">
186208
<div className="border-border-primary relative h-33.5 overflow-hidden rounded-lg border">
187209
{artwork.imageUrl ? (
188210
<Image src={artwork.imageUrl} alt="" fill sizes="168px" className="object-cover" />
@@ -211,7 +233,8 @@ function ArtworkCard({ artwork }: ArtworkCardProps) {
211233
<p className="text-label text-text-secondary font-regular truncate">{artwork.type}</p>
212234
</div>
213235
</div>
214-
</article>
236+
</article>
237+
</Link>
215238
);
216239
}
217240

@@ -228,6 +251,12 @@ export default function MypagePage() {
228251
queryFn: ({ signal }) => getNicknamePolicy(signal),
229252
enabled: canFetchProfile,
230253
});
254+
const mypageFeedQuery = useQuery({
255+
queryKey: ["mypage", "feed", 0, 4],
256+
queryFn: () => getMypageFeed({ page: 0, size: 4 }),
257+
enabled: canFetchProfile,
258+
});
259+
const activitiesQuery = useExhibitions("CONFIRMED", 0, 3);
231260

232261
if (!canFetchProfile) return null;
233262

@@ -241,7 +270,14 @@ export default function MypagePage() {
241270
snsUrl: meQuery.data?.snsUrl ?? null,
242271
}
243272
: null;
244-
const { exhibitions, artworks } = EMPTY_MYPAGE_DATA;
273+
const artworks = (mypageFeedQuery.data?.items ?? []).map(toMypageFeedItem);
274+
const exhibitions: MypageExhibition[] = (activitiesQuery.data?.items ?? []).map(exhibition => ({
275+
id: String(exhibition.id),
276+
imageUrl: normalizeImageUrl(exhibition.thumbnailUrl),
277+
title: exhibition.title,
278+
period: formatPeriod(exhibition.startDate, exhibition.endDate),
279+
place: exhibition.spaceName,
280+
}));
245281
const isProfileLoading = meQuery.isLoading || nicknamePolicyQuery.isLoading;
246282

247283
return (
@@ -287,7 +323,9 @@ export default function MypagePage() {
287323
<section className="flex flex-col gap-4">
288324
<SectionHeader title="피드" moreHref="/mypage/feed" />
289325

290-
{artworks.length > 0 ? (
326+
{mypageFeedQuery.isLoading ? (
327+
<EmptyState message="피드를 불러오는 중입니다." />
328+
) : artworks.length > 0 ? (
291329
<div className="grid grid-cols-2 gap-x-3.5 gap-y-3.5">
292330
{artworks.map(artwork => (
293331
<ArtworkCard key={artwork.id} artwork={artwork} />
@@ -301,7 +339,9 @@ export default function MypagePage() {
301339
<section className="flex flex-col gap-2">
302340
<SectionHeader title="활동 정보" moreHref="/mypage/activities" />
303341

304-
{exhibitions.length > 0 ? (
342+
{activitiesQuery.isLoading ? (
343+
<EmptyState message="활동 정보를 불러오는 중입니다." />
344+
) : exhibitions.length > 0 ? (
305345
<div className="flex flex-col gap-4 py-4">
306346
{exhibitions.map((exhibition, index) => (
307347
<ExhibitionItem

0 commit comments

Comments
 (0)