Skip to content

Commit c9613c9

Browse files
committed
2 parents 616b244 + a855885 commit c9613c9

File tree

8 files changed

+226
-99
lines changed

8 files changed

+226
-99
lines changed

src/app/(public)/home/page.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,28 @@ import FeaturedSlider from "@/components/home/featured-slider";
22
import HeroSlider from "@/components/home/hero-slider";
33
import PlannerBanner from "@/components/home/PlannerBanner";
44
import UpcomingSlider from "@/components/home/upcoming-slider";
5-
import UpcomingSkeleton from "@/components/loading/UpcomingSkeleton";
5+
import FeaturedArtistsSkeleton from "@/components/loading/home/FeaturedArtistsSkeleton";
6+
import UpcomingSkeleton from "@/components/loading/home/UpcomingSkeleton";
67
import { getUpcomingConcerts } from "@/lib/api/concerts/concerts.server";
8+
import { getFeaturedArtists } from "@/lib/artists/artists.server";
9+
import { getAuthStatus } from "@/lib/auth/auth.server";
710
import { Suspense } from "react";
811

912
export default async function Page() {
13+
const isAuthenticated = await getAuthStatus();
1014
const concertData = await getUpcomingConcerts();
15+
const artistData = await getFeaturedArtists({ page: 0, size: 20 });
1116

1217
return (
1318
<>
1419
<HeroSlider />
20+
<UpcomingSkeleton />
1521
<Suspense fallback={<UpcomingSkeleton />}>
1622
<UpcomingSlider concerts={concertData.data} />
1723
</Suspense>
18-
<FeaturedSlider />
24+
<Suspense fallback={<FeaturedArtistsSkeleton />}>
25+
<FeaturedSlider artists={artistData.data} isAuthenticated={isAuthenticated} />
26+
</Suspense>
1927
<PlannerBanner />
2028
</>
2129
);

src/components/home/featured-slider/ArtistCard.tsx

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,77 @@
1-
import { MouseEvent } from "react";
1+
"use client";
2+
23
import Link from "next/link";
34
import { UsersRound } from "lucide-react";
45
import { Card } from "@/components/ui/card";
56
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
67
import { Button } from "@/components/ui/button";
8+
import { ArtistListContent } from "@/types/artists";
9+
import { toast } from "sonner";
10+
import { MouseEvent, useState } from "react";
11+
import { deleteLikeArtist, postLikeArtist } from "@/lib/artists/artists.client";
712

8-
interface Artist {
9-
id: number;
10-
name: string;
11-
genre: string;
12-
imageUrl: string;
13-
followers: string;
14-
}
13+
export default function ArtistCard({
14+
artist,
15+
isAuthenticated,
16+
}: {
17+
artist: ArtistListContent;
18+
isAuthenticated: boolean;
19+
}) {
20+
const [currentFollows, setCurrentFollows] = useState<number>(artist.likeCount);
21+
const [currentLiked, setCurrentLiked] = useState<boolean>(artist.isLiked ?? false);
1522

16-
interface ArtistCardProps {
17-
artist: Artist;
18-
onFollow: (e: MouseEvent<HTMLButtonElement>) => void;
19-
}
23+
const handleFollow = async (e: MouseEvent<HTMLButtonElement>, artistId: number) => {
24+
e.preventDefault();
25+
e.stopPropagation();
26+
27+
if (!isAuthenticated) {
28+
toast.error("로그인 후 이용해주세요.");
29+
return;
30+
}
31+
32+
// TODO : 낙관적 업데이트 고도화 적용
33+
if (currentLiked) {
34+
await deleteLikeArtist(artistId);
35+
setCurrentFollows((prev) => prev - 1);
36+
toast.success("해당 아티스트를 찜 해제했습니다.");
37+
} else {
38+
await postLikeArtist(artistId);
39+
setCurrentFollows((prev) => prev + 1);
40+
toast.success("해당 아티스트를 찜했습니다.");
41+
}
42+
setCurrentLiked(!currentLiked);
43+
};
2044

21-
export default function ArtistCard({ artist, onFollow }: ArtistCardProps) {
2245
return (
2346
<Link href={`/artists/${artist.id}`} className="block">
24-
<Card className="flex h-full flex-col items-center gap-3 p-4 text-center shadow-none transition-transform md:gap-4 md:p-6 lg:p-8">
47+
<Card className="flex h-full flex-col items-center gap-3 p-4 text-center shadow-none transition-transform hover:opacity-90 md:gap-4 md:p-6 lg:p-8">
2548
{/* 아바타 */}
2649
<Avatar className="ring-border size-20 ring-4 md:size-24 lg:size-30">
27-
<AvatarImage src={artist.imageUrl} alt={artist.name} />
28-
<AvatarFallback>{artist.name.slice(0, 2).toUpperCase()}</AvatarFallback>
50+
<AvatarImage src={artist.imageUrl} alt={artist.nameKo ?? artist.artistName} />
51+
<AvatarFallback>{artist.nameKo ?? artist.artistName}</AvatarFallback>
2952
</Avatar>
3053

3154
{/* 아티스트 정보 */}
3255
<div className="space-y-0.5">
33-
<h3 className="text-text-main text-base font-bold md:text-lg">{artist.name}</h3>
34-
<p className="text-text-sub text-xs font-semibold md:text-sm">{artist.genre}</p>
56+
<h3 className="text-text-main text-base font-bold md:text-lg">
57+
{artist.nameKo ?? artist.artistName}
58+
</h3>
59+
<p className="text-text-sub text-xs font-semibold md:text-sm">{artist.genres}</p>
3560
</div>
3661

3762
{/* 팔로워 수 */}
3863
<div className="text-text-sub flex items-center gap-1.5 text-xs font-semibold md:text-sm">
3964
<UsersRound className="size-3 md:size-3.5" strokeWidth={3} />
40-
<span>{artist.followers} 팔로우 중</span>
65+
<span>{currentFollows} 팔로우 중</span>
4166
</div>
4267

4368
{/* 팔로우 버튼 */}
44-
<Button variant="default" className="w-full text-sm md:text-base" onClick={onFollow}>
45-
팔로우
69+
<Button
70+
variant={currentLiked ? "outline" : "default"}
71+
className="w-full text-sm md:text-base"
72+
onClick={(e) => handleFollow(e, artist.id)}
73+
>
74+
{currentLiked ? "팔로잉" : "팔로우"}
4675
</Button>
4776
</Card>
4877
</Link>

src/components/home/featured-slider/index.tsx

Lines changed: 11 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,23 @@
11
"use client";
22

3-
import { MouseEvent, useState } from "react";
3+
import { useState } from "react";
44
import { Swiper, SwiperSlide } from "swiper/react";
55
import type { Swiper as SwiperType } from "swiper";
66
import { SliderHeader } from "../SliderHeader";
77
import "swiper/css";
8-
import { toast } from "sonner";
98
import ArtistCard from "./ArtistCard";
9+
import { ArtistListData } from "@/types/artists";
1010

11-
// 임시 데이터 타입
12-
interface Artist {
13-
id: number;
14-
name: string;
15-
genre: string;
16-
imageUrl: string;
17-
followers: string;
18-
}
19-
20-
interface FeaturedSliderProps {
21-
artists?: Artist[] | null;
22-
}
23-
24-
export default function FeaturedSlider({ artists }: FeaturedSliderProps) {
11+
export default function FeaturedSlider({
12+
artists,
13+
isAuthenticated,
14+
}: {
15+
artists: ArtistListData | null;
16+
isAuthenticated: boolean;
17+
}) {
2518
const [swiperInstance, setSwiperInstance] = useState<SwiperType | null>(null);
2619

27-
// 임시 데이터
28-
const mockArtists: Artist[] = Array.from({ length: 10 }).map((_, index) => ({
29-
id: index,
30-
name: "먼데이키즈",
31-
genre: "발라드 가수",
32-
imageUrl:
33-
"https://kopis.or.kr/_next/image?url=%2Fupload%2FpfmPoster%2FPF_PF281383_251211_125646.jpg&w=384&q=75",
34-
followers: "24.5K",
35-
}));
36-
37-
const displayArtists = artists || mockArtists;
38-
39-
const handleFollow = (e: MouseEvent<HTMLButtonElement>, artistId: number) => {
40-
e.preventDefault();
41-
e.stopPropagation();
42-
toast.success(`아티스트 ${artistId} 팔로우 되었습니다!`);
43-
};
20+
const displayArtists = artists?.content;
4421

4522
if (!displayArtists?.length) return null;
4623

@@ -81,7 +58,7 @@ export default function FeaturedSlider({ artists }: FeaturedSliderProps) {
8158
>
8259
{displayArtists.map((artist) => (
8360
<SwiperSlide key={artist.id}>
84-
<ArtistCard artist={artist} onFollow={(e) => handleFollow(e, artist.id)} />
61+
<ArtistCard artist={artist} isAuthenticated={isAuthenticated} />
8562
</SwiperSlide>
8663
))}
8764
</Swiper>

src/components/loading/UpcomingSkeleton.tsx

Lines changed: 0 additions & 40 deletions
This file was deleted.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Card } from "@/components/ui/card";
2+
import { Skeleton } from "@/components/ui/skeleton";
3+
import { cn } from "@/lib/utils";
4+
5+
export default function FeaturedArtistsSkeleton() {
6+
return (
7+
<section className="bg-bg-sub w-full overflow-hidden px-5 py-10 md:py-15 lg:px-15 lg:py-20">
8+
<div className="mx-auto flex w-full max-w-400 flex-col gap-6 lg:gap-10">
9+
<div className="flex items-end justify-between gap-4">
10+
<div className="w-full max-w-lg space-y-2">
11+
<Skeleton className="h-8 w-3/4 max-w-80 md:h-9 md:w-96" />
12+
<Skeleton className="h-5 w-1/2 max-w-60 md:h-6 md:w-72" />
13+
</div>
14+
15+
{/* 네비게이션 버튼 영역 (모바일 숨김, 데스크탑 노출) */}
16+
<div className="hidden gap-4 md:flex">
17+
<Skeleton className="size-12 rounded-full" />
18+
<Skeleton className="size-12 rounded-full" />
19+
</div>
20+
</div>
21+
22+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 lg:gap-5 xl:grid-cols-5 xl:gap-6">
23+
{Array.from({ length: 5 }).map((_, index) => (
24+
<div
25+
key={index}
26+
className={cn(
27+
index >= 2 && "hidden sm:block",
28+
index >= 3 && "sm:hidden lg:block",
29+
index >= 4 && "lg:hidden xl:block"
30+
)}
31+
>
32+
<Card className="flex h-full flex-col items-center gap-3 p-4 text-center shadow-none md:gap-4 md:p-6 lg:p-8">
33+
<Skeleton className="size-20 rounded-full md:size-24 lg:size-30" />
34+
<div className="flex w-full flex-col items-center space-y-2 py-1">
35+
<Skeleton className="h-6 w-3/4 md:h-7" />
36+
<Skeleton className="h-3 w-1/2 md:h-4" />
37+
</div>
38+
<div className="flex items-center gap-1.5 py-1">
39+
<Skeleton className="size-3 rounded-full md:size-3.5" />
40+
<Skeleton className="h-3 w-16 md:h-4" />
41+
</div>
42+
<Skeleton className="mt-1 h-9 w-full rounded-md md:h-10" />
43+
</Card>
44+
</div>
45+
))}
46+
</div>
47+
</div>
48+
</section>
49+
);
50+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Skeleton } from "@/components/ui/skeleton";
2+
3+
export default function UpcomingSliderSkeleton() {
4+
return (
5+
<section className="w-full overflow-hidden py-10 md:py-15 lg:py-20">
6+
<div className="flex flex-col gap-6 px-5 lg:gap-10 lg:px-15">
7+
<div className="mx-auto flex w-full max-w-400 items-end justify-between gap-4">
8+
<div className="w-full space-y-2 md:w-auto">
9+
<Skeleton className="h-7 w-48 md:h-9 md:w-80 lg:w-96" />
10+
<Skeleton className="h-4 w-32 md:h-6 md:w-60 lg:w-80" />
11+
</div>
12+
13+
<div className="hidden gap-4 md:flex">
14+
<Skeleton className="size-10 rounded-full md:size-12" />
15+
<Skeleton className="size-10 rounded-full md:size-12" />
16+
</div>
17+
</div>
18+
19+
<div className="mx-auto w-full max-w-400">
20+
<div className="w-full overflow-visible">
21+
<div className="flex">
22+
{Array.from({ length: 4 }).map((_, index) => (
23+
<div key={index} className="relative block w-auto! pr-3 last:pr-0 md:pr-4 lg:pr-8">
24+
<div className="relative w-64 md:w-72 lg:w-80">
25+
<Skeleton className="aspect-[320/426.5] w-full rounded-2xl" />
26+
<div className="pointer-events-none absolute top-[58.8%] left-0 -mt-4 w-full">
27+
<div className="bg-background absolute -left-4 h-8 w-8 rounded-full" />
28+
<div className="bg-background absolute -right-4 h-8 w-8 rounded-full" />
29+
</div>
30+
<div className="pointer-events-none">
31+
<div className="bg-background absolute -top-4 -left-4 h-8 w-8 rounded-full" />
32+
<div className="bg-background absolute -top-4 -right-4 h-8 w-8 rounded-full" />
33+
<div className="bg-background absolute -bottom-4 -left-4 h-8 w-8 rounded-full" />
34+
<div className="bg-background absolute -right-4 -bottom-4 h-8 w-8 rounded-full" />
35+
</div>
36+
</div>
37+
</div>
38+
))}
39+
</div>
40+
</div>
41+
</div>
42+
</div>
43+
</section>
44+
);
45+
}

src/lib/artists/artists.client.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ArtistListContent, ArtistListResponse } from "@/types/artists";
2+
import ClientApi from "@/utils/helpers/clientApi";
23

34
// 아티스트 목록 불러오기
45
export async function getArtists(
@@ -54,3 +55,27 @@ export async function getArtists(
5455
throw new Error("알 수 없는 오류가 발생했습니다.");
5556
}
5657
}
58+
59+
// 아티스트 좋아요
60+
export async function postLikeArtist(artistId: number): Promise<boolean> {
61+
try {
62+
const res = await ClientApi(`/api/v1/artists/likes/${artistId}`, {
63+
method: "POST",
64+
});
65+
return res.ok;
66+
} catch {
67+
return false;
68+
}
69+
}
70+
71+
// 아티스트 좋아요 취소
72+
export async function deleteLikeArtist(artistId: number): Promise<boolean> {
73+
try {
74+
const res = await ClientApi(`/api/v1/artists/likes/${artistId}`, {
75+
method: "DELETE",
76+
});
77+
return res.ok;
78+
} catch {
79+
return false;
80+
}
81+
}

0 commit comments

Comments
 (0)