Skip to content

Commit 0b0128f

Browse files
authored
앨범 상세 페이지 구현 (#198)
* refactor: 댓글 응답 값에 날짜 추가 * feat: 앨범 상세 페이지 구현 * build: fast average color 라이브러리 추가 * feat: 앨범 상세 페이지 배경 설정 * feat: 노래 제목 옆 숫자 출력 및 코멘트 익명 출력 * refactor: 프록시 설정 제거 * refactor: 배경 그라데이션 위치 수정 및 부모 영역 패딩 추가 * feat: 댓글 작성 기능 구현 * style: 콘텐츠 영역이 화면 밖으로 나가지 않도록 스타일 개선 * refactor: comment 데이터 서버 응답 시 최신순으로 정렬하여 반환 * refactor: comment 데이터 서버 응답 시 최신 10개만 반환 하도록 수정 * refactor: 댓글 저장 시 제한된 글자 수 이내로 작성했는지 검증 * feat: 앨범명 및 부가 정보 추가 * fix: 재생 시간 계산 문제 해결 * refactor: 아티스트 정보 출력 길이 수정 및 컴포넌트 분리 * style: 댓글 텍스트 수정 * style: 제목 및 아티스트명 출력 영역 확장 * style: 플레이리스트 출력 영역 확장 * refactor: 불필요한 then 메소드 사용 제거 * refactor: useEffect에서 albumId가 수정될 때 useEffect가 호출되도록 수정 * refactor: 이미지 alt 추가 * refactor: 정적 px 값 tailwind 방식으로 수정 * refactor: 스크롤 바 색 수정 * refactor: 불필요한 export 제거
1 parent e97dbdd commit 0b0128f

File tree

17 files changed

+375
-68
lines changed

17 files changed

+375
-68
lines changed

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"build-storybook": "storybook build"
1111
},
1212
"dependencies": {
13+
"fast-average-color": "^9.4.0",
1314
"react": "^18.2.0",
1415
"react-dom": "^18.2.0",
1516
"socket.io-client": "^4.8.1"

client/src/App.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { Routes, Route } from 'react-router-dom';
1+
import { Outlet, Route, Routes } from 'react-router-dom';
22
import { MainPage } from '@/pages/MainPage';
33
import { StreamingPage } from '@/pages/StreamingPage';
44
import { AdminPage } from '@/pages/AdminPage';
55
import { AlbumPage } from '@/pages/AlbumPage';
66
import { AdminLoginPage } from '@/pages/AdminLoginPage';
77
import { ProtectedRoute } from '@/components/ProtectedRoute';
8-
import { Outlet } from 'react-router-dom';
98
import { Sidebar } from './widgets/sidebar/ui/Sidebar';
109
import { GlobalBoundary } from './GlobalBoundary';
1110

client/src/app/router/routes.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { MainPage } from '@/pages/MainPage';
22
import { StreamingPage } from '@/pages/StreamingPage';
33
import { Layout } from '@/Layout';
44
import { AdminPage } from '@/pages/AdminPage';
5-
import { AlbumPage } from '@/pages/AlbumPage';
65
import { AdminLoginPage } from '@/pages/AdminLoginPage';
76
import { ProtectedRoute } from '@/components/ProtectedRoute';
7+
import { AlbumPage } from '@/pages/AlbumPage';
88

99
export const routes = [
1010
{
Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,105 @@
1+
import { AlbumArtist, CommentList, Playlist } from '@/widgets/albums';
2+
import { publicAPI } from '@/shared/api/publicAPI.ts';
3+
import { useParams } from 'react-router-dom';
4+
import { useEffect, useState } from 'react';
5+
import { FastAverageColor } from 'fast-average-color';
6+
import { darken } from 'polished';
7+
8+
interface AlbumDetails {
9+
albumId: string;
10+
albumName: string;
11+
artist: string;
12+
jacketUrl: string;
13+
}
14+
115
export function AlbumPage() {
2-
return <div>AlbumPage</div>;
16+
const { albumId } = useParams<{ albumId: string }>();
17+
if (!albumId) return;
18+
19+
const [songDetails, setSongDetails] = useState<
20+
{ name: string; duration: string }[]
21+
>([]);
22+
const [albumJacketUrl, setAlbumJacketUrl] = useState<string>('LogoAlbum');
23+
const [albumDetails, setAlbumDetails] = useState<AlbumDetails>({});
24+
25+
useEffect(() => {
26+
(async () => {
27+
const albumResponse = await publicAPI
28+
.getAlbumInfo(albumId)
29+
.catch((err) => console.log(err));
30+
31+
setAlbumDetails(albumResponse.result.albumDetails);
32+
setSongDetails(albumResponse.result.songDetails);
33+
setAlbumJacketUrl(albumResponse.result.albumDetails.jacketUrl);
34+
})();
35+
}, [albumJacketUrl, albumId]);
36+
37+
const [backgroundColor, setBackgroundColor] = useState<string>('#222');
38+
39+
useEffect(() => {
40+
const fac = new FastAverageColor();
41+
const img = new Image();
42+
img.crossOrigin = 'anonymous';
43+
img.src = albumJacketUrl;
44+
45+
img.onload = () => {
46+
try {
47+
if (img.width === 0 || img.height === 0) {
48+
console.error('Image has no dimensions');
49+
return;
50+
}
51+
52+
const color = fac.getColor(img, {
53+
algorithm: 'dominant', // 주요 색상 추출
54+
mode: 'precision', // 더 정확한 색상 계산
55+
});
56+
57+
setBackgroundColor(darken(0.4, color.hex));
58+
} catch (e) {
59+
console.error('Color extraction failed:', e);
60+
}
61+
};
62+
63+
return () => {
64+
img.onload = null; // 클린업
65+
};
66+
}, [albumJacketUrl]); // albumJacketUrl이 변경될 때마다 실행
67+
68+
const totalDuration = songDetails.reduce(
69+
(total, acc) => total + Number(acc.songDuration),
70+
0,
71+
);
72+
73+
return (
74+
<div
75+
className={'px-80 pt-16 flex flex-col w-full'}
76+
style={{
77+
background: `linear-gradient(180deg, ${backgroundColor} 0%, rgba(0, 0, 0, 0) 20%)`,
78+
}}
79+
>
80+
<div className={'flex h-680 gap-20 mb-24 relative z-10'}>
81+
<article className={'w-[21.25rem] h-85 flex-shrink-0'}>
82+
<img
83+
id={'album-jacket'}
84+
src={albumJacketUrl}
85+
className={'w-[21.25rem] h-[21.25rem] select-none'}
86+
alt={`${albumDetails.albumName} 앨범 커버`}
87+
></img>
88+
<p
89+
className={`${albumDetails.albumName?.length >= 12 ? 'text-2xl' : albumDetails.albumName?.length >= 10 ? 'text-3xl' : 'text-4xl'} text-grayscale-50 mt-8 truncate`}
90+
style={{ fontWeight: 900 }}
91+
>
92+
{albumDetails.albumName}
93+
</p>
94+
<AlbumArtist
95+
artist={albumDetails.artist}
96+
songLength={songDetails.length}
97+
totalDuration={totalDuration}
98+
/>
99+
</article>
100+
<Playlist playlist={songDetails} />
101+
</div>
102+
<CommentList albumId={albumId} />
103+
</div>
104+
);
3105
}

client/src/shared/api/errorMessage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,7 @@ export const ERROR_MESSAGE = {
1212
DEFAULT: {
1313
UNKNOWN_ERROR: '알 수 없는 에러가 발생했습니다.',
1414
},
15+
COMMENT: {
16+
COMMENT_MESSAGE_TO_LONG: '댓글은 200자 이내로 작성해주세요.',
17+
},
1518
};

client/src/shared/api/publicAPI.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,22 @@ export const publicAPI = {
5353
throw error;
5454
}
5555
},
56+
getComment: async (albumId: string) => {
57+
const { data } = await publicInstance.get(`/comment/album/${albumId}`);
58+
return data;
59+
},
60+
getAlbumInfo: async (albumId: string) => {
61+
const { data } = await publicInstance.get(`/album/${albumId}`);
62+
return data;
63+
},
64+
createComment: async (albumId: string, content: string) => {
65+
if (content.length === 0 || content.length > 200) {
66+
alert(ERROR_MESSAGE.COMMENT.COMMENT_MESSAGE_TO_LONG);
67+
throw new CustomError(ERROR_MESSAGE.COMMENT.COMMENT_MESSAGE_TO_LONG);
68+
}
69+
const { data } = await publicInstance.post(`/comment/album/${albumId}`, {
70+
content,
71+
});
72+
return data;
73+
},
5674
};

client/src/widgets/albums/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
export { AlbumList } from './ui/AlbumList';
2+
export { CommentList } from './ui/CommentList.tsx';
3+
export { Playlist } from './ui/Playlist';
4+
export { AlbumArtist } from './ui/AlbumArtist';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
interface AlbumArtistProps {
2+
artist: string;
3+
songLength: number;
4+
totalDuration: number;
5+
}
6+
7+
export function AlbumArtist({
8+
artist,
9+
songLength,
10+
totalDuration,
11+
}: AlbumArtistProps) {
12+
const hour = Math.floor(Number(totalDuration) / 3600);
13+
const minute = Math.floor((Number(totalDuration) % 3600) / 60);
14+
const second = Math.floor(Number(totalDuration) % 60);
15+
16+
return (
17+
<section
18+
className={
19+
'text-lg text-grayscale-400 mt-4 flex justify-start overflow-visible whitespace-nowrap absolute max-w-[calc(100vw-340px)]'
20+
}
21+
>
22+
<span className={'truncate'}>{artist}</span>
23+
<p className={'flex-shrink-0 flex-grow-0 whitespace-nowrap'}>
24+
<span className={'mx-2'}></span>
25+
<span>{songLength}</span>
26+
</p>
27+
<p className={'flex-shrink-0 flex-grow-0 whitespace-nowrap'}>
28+
<span className={'mx-2'}></span>
29+
<span>
30+
{(hour > 0 ? String(hour).padStart(2, '0') + '시간 ' : '') +
31+
String(minute).padStart(2, '0') +
32+
'분 ' +
33+
String(second).padStart(2, '0') +
34+
'초'}
35+
</span>
36+
</p>
37+
</section>
38+
);
39+
}

client/src/widgets/albums/ui/AlbumList.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,18 @@ export function AlbumList() {
2626
<p className="mt-[70px] mb-7 text-3xl font-bold">최근 등록된 앨범</p>
2727
<ul className="w-full grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 gap-9">
2828
{endedAlbumList.endedAlbums.slice(0, 7).map((album) => (
29-
<AlbumCard
30-
key={album.albumId}
31-
album={{
32-
albumId: album.albumId,
33-
albumName: album.albumName,
34-
artist: album.artist,
35-
albumTags: album.albumTags || '',
36-
jacketUrl: album.jacketUrl || LogoAlbum,
37-
}}
38-
/>
29+
<a href={`/album/${album.albumId}`}>
30+
<AlbumCard
31+
key={album.albumId}
32+
album={{
33+
albumId: album.albumId,
34+
albumName: album.albumName,
35+
artist: album.artist,
36+
albumTags: album.albumTags || '',
37+
jacketUrl: album.jacketUrl || LogoAlbum,
38+
}}
39+
/>
40+
</a>
3941
))}
4042
</ul>
4143
</div>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
interface CommentProps {
2+
comment: { content: string; createdAt: Date };
3+
index: number;
4+
}
5+
6+
export function Comment({ comment, index }: CommentProps) {
7+
const date: Date = new Date(comment.createdAt);
8+
const dateFormat = `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}.`;
9+
return (
10+
<article className={'w-full flex justify-between mb-4 overflow-hidden'}>
11+
<p className={'w-[80px] mr-[24px] flex-shrink-0'}>댓글 #{index + 1}</p>
12+
<p className={'word-break break-all flex-grow'}>{comment.content}</p>
13+
<p className={'w-[90px] ml-[24px] flex-shrink-0'}>{dateFormat}</p>
14+
</article>
15+
);
16+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Comment } from './Comment.tsx';
2+
import { publicAPI } from '@/shared/api/publicAPI.ts';
3+
import { Button } from '@/shared/ui';
4+
import { useEffect, useState } from 'react';
5+
6+
interface CommentListProps {
7+
albumId: string;
8+
}
9+
10+
export function CommentList({ albumId }: CommentListProps) {
11+
const [commentList, setCommentList] = useState<{ albumName: string }[]>([]);
12+
13+
useEffect(() => {
14+
(async () => {
15+
const commentResponse = await publicAPI
16+
.getComment(albumId)
17+
.catch((err) => console.log(err));
18+
19+
setCommentList(commentResponse.result.albumComments);
20+
})();
21+
}, [commentList]);
22+
23+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
24+
e.preventDefault();
25+
26+
const dom = e.currentTarget.previousElementSibling;
27+
const response = publicAPI.createComment(albumId, dom.value);
28+
29+
response.then((res) => {
30+
alert('댓글이 등록되었습니다.');
31+
setCommentList([dom.value, ...commentList]);
32+
dom.parentElement.reset();
33+
});
34+
};
35+
36+
return (
37+
<div className="w-full text-grayscale-50 border-t border-grayscale-700 border-solid">
38+
<p className={'text-4xl font-bold mb-8 mt-14'}>
39+
코멘트
40+
<span className={'ml-6 text-sm font-normal text-grayscale-400'}>
41+
최신 10개의 댓글만 조회합니다.
42+
</span>
43+
</p>
44+
<form className={'flex justify-between items-baseline mb-12'}>
45+
<p className={'w-20 mr-6'}>댓글 작성</p>
46+
<input
47+
name={'content'}
48+
className={
49+
'bg-transparent border-b border-solid h-10 py-2 focus:outline-none flex-grow mr-16'
50+
}
51+
placeholder={'여기에 댓글을 입력해주세요.'}
52+
maxLength={200}
53+
/>
54+
<Button type={'submit'} message={'등록'} onClick={handleSubmit} />
55+
</form>
56+
{commentList.map((comment, index) => (
57+
<Comment key={index} comment={comment} index={index} />
58+
))}
59+
</div>
60+
);
61+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { TrackItem } from './TrackItem';
2+
import './Scrollbar.css';
3+
4+
export interface PlaylistComponentProps {
5+
playlist: { songName: string; songDuration: string }[];
6+
}
7+
8+
export function Playlist({ playlist }: PlaylistComponentProps) {
9+
return (
10+
<article className={'w-full overflow-y-scroll h-96 pr-4'}>
11+
{playlist.map(
12+
(item: { songName: string; songDuration: string }, index) => (
13+
<TrackItem trackData={item} index={index} key={index} />
14+
),
15+
)}
16+
</article>
17+
);
18+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
::-webkit-scrollbar-thumb {
2+
background: #fafafa !important;
3+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
interface TrackItemProps {
2+
trackData: { songName: string; songDuration: string };
3+
index: number;
4+
}
5+
6+
export function TrackItem({ trackData, index }: TrackItemProps) {
7+
const hour = Math.floor(Number(trackData.songDuration) / 3600);
8+
const minute = Math.floor((Number(trackData.songDuration) % 3600) / 60);
9+
const second = Math.floor(Number(trackData.songDuration) % 60);
10+
11+
return (
12+
<section
13+
className={
14+
'flex w-full h-[30px] text-grayscale-50 justify-between mb-[24px]'
15+
}
16+
>
17+
<section className={'flex'}>
18+
<section className={'mr-8'}>{index + 1}.</section>
19+
<section>{trackData.songName}</section>
20+
</section>
21+
22+
<section className={'text-grayscale-200 text-sm'}>
23+
{(hour > 0 ? String(hour).padStart(2, '0') + ':' : '') +
24+
String(minute).padStart(2, '0') +
25+
':' +
26+
String(second).padStart(2, '0')}
27+
</section>
28+
</section>
29+
);
30+
}

0 commit comments

Comments
 (0)