diff --git a/client/package.json b/client/package.json
index 6440d482..18d763ff 100644
--- a/client/package.json
+++ b/client/package.json
@@ -10,6 +10,7 @@
"build-storybook": "storybook build"
},
"dependencies": {
+ "fast-average-color": "^9.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"socket.io-client": "^4.8.1"
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 83430b66..fe8cc5d4 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,11 +1,10 @@
-import { Routes, Route } from 'react-router-dom';
+import { Outlet, Route, Routes } from 'react-router-dom';
import { MainPage } from '@/pages/MainPage';
import { StreamingPage } from '@/pages/StreamingPage';
import { AdminPage } from '@/pages/AdminPage';
import { AlbumPage } from '@/pages/AlbumPage';
import { AdminLoginPage } from '@/pages/AdminLoginPage';
import { ProtectedRoute } from '@/components/ProtectedRoute';
-import { Outlet } from 'react-router-dom';
import { Sidebar } from './widgets/sidebar/ui/Sidebar';
import { GlobalBoundary } from './GlobalBoundary';
diff --git a/client/src/app/router/routes.tsx b/client/src/app/router/routes.tsx
index caeb6926..feed506e 100644
--- a/client/src/app/router/routes.tsx
+++ b/client/src/app/router/routes.tsx
@@ -2,9 +2,9 @@ import { MainPage } from '@/pages/MainPage';
import { StreamingPage } from '@/pages/StreamingPage';
import { Layout } from '@/Layout';
import { AdminPage } from '@/pages/AdminPage';
-import { AlbumPage } from '@/pages/AlbumPage';
import { AdminLoginPage } from '@/pages/AdminLoginPage';
import { ProtectedRoute } from '@/components/ProtectedRoute';
+import { AlbumPage } from '@/pages/AlbumPage';
export const routes = [
{
diff --git a/client/src/pages/AlbumPage/ui/AlbumPage.tsx b/client/src/pages/AlbumPage/ui/AlbumPage.tsx
index 347c4169..3e524ee3 100644
--- a/client/src/pages/AlbumPage/ui/AlbumPage.tsx
+++ b/client/src/pages/AlbumPage/ui/AlbumPage.tsx
@@ -1,3 +1,105 @@
+import { AlbumArtist, CommentList, Playlist } from '@/widgets/albums';
+import { publicAPI } from '@/shared/api/publicAPI.ts';
+import { useParams } from 'react-router-dom';
+import { useEffect, useState } from 'react';
+import { FastAverageColor } from 'fast-average-color';
+import { darken } from 'polished';
+
+interface AlbumDetails {
+ albumId: string;
+ albumName: string;
+ artist: string;
+ jacketUrl: string;
+}
+
export function AlbumPage() {
- return
AlbumPage
;
+ const { albumId } = useParams<{ albumId: string }>();
+ if (!albumId) return;
+
+ const [songDetails, setSongDetails] = useState<
+ { name: string; duration: string }[]
+ >([]);
+ const [albumJacketUrl, setAlbumJacketUrl] = useState('LogoAlbum');
+ const [albumDetails, setAlbumDetails] = useState({});
+
+ useEffect(() => {
+ (async () => {
+ const albumResponse = await publicAPI
+ .getAlbumInfo(albumId)
+ .catch((err) => console.log(err));
+
+ setAlbumDetails(albumResponse.result.albumDetails);
+ setSongDetails(albumResponse.result.songDetails);
+ setAlbumJacketUrl(albumResponse.result.albumDetails.jacketUrl);
+ })();
+ }, [albumJacketUrl, albumId]);
+
+ const [backgroundColor, setBackgroundColor] = useState('#222');
+
+ useEffect(() => {
+ const fac = new FastAverageColor();
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.src = albumJacketUrl;
+
+ img.onload = () => {
+ try {
+ if (img.width === 0 || img.height === 0) {
+ console.error('Image has no dimensions');
+ return;
+ }
+
+ const color = fac.getColor(img, {
+ algorithm: 'dominant', // 주요 색상 추출
+ mode: 'precision', // 더 정확한 색상 계산
+ });
+
+ setBackgroundColor(darken(0.4, color.hex));
+ } catch (e) {
+ console.error('Color extraction failed:', e);
+ }
+ };
+
+ return () => {
+ img.onload = null; // 클린업
+ };
+ }, [albumJacketUrl]); // albumJacketUrl이 변경될 때마다 실행
+
+ const totalDuration = songDetails.reduce(
+ (total, acc) => total + Number(acc.songDuration),
+ 0,
+ );
+
+ return (
+
+
+
+
+ = 12 ? 'text-2xl' : albumDetails.albumName?.length >= 10 ? 'text-3xl' : 'text-4xl'} text-grayscale-50 mt-8 truncate`}
+ style={{ fontWeight: 900 }}
+ >
+ {albumDetails.albumName}
+
+
+
+
+
+
+
+ );
}
diff --git a/client/src/shared/api/errorMessage.ts b/client/src/shared/api/errorMessage.ts
index a932c9c1..203d755a 100644
--- a/client/src/shared/api/errorMessage.ts
+++ b/client/src/shared/api/errorMessage.ts
@@ -12,4 +12,7 @@ export const ERROR_MESSAGE = {
DEFAULT: {
UNKNOWN_ERROR: '알 수 없는 에러가 발생했습니다.',
},
+ COMMENT: {
+ COMMENT_MESSAGE_TO_LONG: '댓글은 200자 이내로 작성해주세요.',
+ },
};
diff --git a/client/src/shared/api/publicAPI.ts b/client/src/shared/api/publicAPI.ts
index 1d8fcb07..0335f753 100644
--- a/client/src/shared/api/publicAPI.ts
+++ b/client/src/shared/api/publicAPI.ts
@@ -53,4 +53,22 @@ export const publicAPI = {
throw error;
}
},
+ getComment: async (albumId: string) => {
+ const { data } = await publicInstance.get(`/comment/album/${albumId}`);
+ return data;
+ },
+ getAlbumInfo: async (albumId: string) => {
+ const { data } = await publicInstance.get(`/album/${albumId}`);
+ return data;
+ },
+ createComment: async (albumId: string, content: string) => {
+ if (content.length === 0 || content.length > 200) {
+ alert(ERROR_MESSAGE.COMMENT.COMMENT_MESSAGE_TO_LONG);
+ throw new CustomError(ERROR_MESSAGE.COMMENT.COMMENT_MESSAGE_TO_LONG);
+ }
+ const { data } = await publicInstance.post(`/comment/album/${albumId}`, {
+ content,
+ });
+ return data;
+ },
};
diff --git a/client/src/widgets/albums/index.ts b/client/src/widgets/albums/index.ts
index 466c23d1..90c72b0f 100644
--- a/client/src/widgets/albums/index.ts
+++ b/client/src/widgets/albums/index.ts
@@ -1 +1,4 @@
export { AlbumList } from './ui/AlbumList';
+export { CommentList } from './ui/CommentList.tsx';
+export { Playlist } from './ui/Playlist';
+export { AlbumArtist } from './ui/AlbumArtist';
diff --git a/client/src/widgets/albums/ui/AlbumArtist.tsx b/client/src/widgets/albums/ui/AlbumArtist.tsx
new file mode 100644
index 00000000..c601580c
--- /dev/null
+++ b/client/src/widgets/albums/ui/AlbumArtist.tsx
@@ -0,0 +1,39 @@
+interface AlbumArtistProps {
+ artist: string;
+ songLength: number;
+ totalDuration: number;
+}
+
+export function AlbumArtist({
+ artist,
+ songLength,
+ totalDuration,
+}: AlbumArtistProps) {
+ const hour = Math.floor(Number(totalDuration) / 3600);
+ const minute = Math.floor((Number(totalDuration) % 3600) / 60);
+ const second = Math.floor(Number(totalDuration) % 60);
+
+ return (
+
+ {artist}
+
+ •
+ {songLength}곡
+
+
+ •
+
+ {(hour > 0 ? String(hour).padStart(2, '0') + '시간 ' : '') +
+ String(minute).padStart(2, '0') +
+ '분 ' +
+ String(second).padStart(2, '0') +
+ '초'}
+
+
+
+ );
+}
diff --git a/client/src/widgets/albums/ui/AlbumList.tsx b/client/src/widgets/albums/ui/AlbumList.tsx
index 07eec56e..7c978bf7 100644
--- a/client/src/widgets/albums/ui/AlbumList.tsx
+++ b/client/src/widgets/albums/ui/AlbumList.tsx
@@ -26,16 +26,18 @@ export function AlbumList() {
최근 등록된 앨범
{endedAlbumList.endedAlbums.slice(0, 7).map((album) => (
-
+
+
+
))}
diff --git a/client/src/widgets/albums/ui/Comment.tsx b/client/src/widgets/albums/ui/Comment.tsx
new file mode 100644
index 00000000..9695dc7d
--- /dev/null
+++ b/client/src/widgets/albums/ui/Comment.tsx
@@ -0,0 +1,16 @@
+interface CommentProps {
+ comment: { content: string; createdAt: Date };
+ index: number;
+}
+
+export function Comment({ comment, index }: CommentProps) {
+ const date: Date = new Date(comment.createdAt);
+ const dateFormat = `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}.`;
+ return (
+
+ 댓글 #{index + 1}
+ {comment.content}
+ {dateFormat}
+
+ );
+}
diff --git a/client/src/widgets/albums/ui/CommentList.tsx b/client/src/widgets/albums/ui/CommentList.tsx
new file mode 100644
index 00000000..99868335
--- /dev/null
+++ b/client/src/widgets/albums/ui/CommentList.tsx
@@ -0,0 +1,61 @@
+import { Comment } from './Comment.tsx';
+import { publicAPI } from '@/shared/api/publicAPI.ts';
+import { Button } from '@/shared/ui';
+import { useEffect, useState } from 'react';
+
+interface CommentListProps {
+ albumId: string;
+}
+
+export function CommentList({ albumId }: CommentListProps) {
+ const [commentList, setCommentList] = useState<{ albumName: string }[]>([]);
+
+ useEffect(() => {
+ (async () => {
+ const commentResponse = await publicAPI
+ .getComment(albumId)
+ .catch((err) => console.log(err));
+
+ setCommentList(commentResponse.result.albumComments);
+ })();
+ }, [commentList]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ const dom = e.currentTarget.previousElementSibling;
+ const response = publicAPI.createComment(albumId, dom.value);
+
+ response.then((res) => {
+ alert('댓글이 등록되었습니다.');
+ setCommentList([dom.value, ...commentList]);
+ dom.parentElement.reset();
+ });
+ };
+
+ return (
+
+
+ 코멘트
+
+ 최신 10개의 댓글만 조회합니다.
+
+
+
+ {commentList.map((comment, index) => (
+
+ ))}
+
+ );
+}
diff --git a/client/src/widgets/albums/ui/Playlist.tsx b/client/src/widgets/albums/ui/Playlist.tsx
new file mode 100644
index 00000000..5d2fb073
--- /dev/null
+++ b/client/src/widgets/albums/ui/Playlist.tsx
@@ -0,0 +1,18 @@
+import { TrackItem } from './TrackItem';
+import './Scrollbar.css';
+
+export interface PlaylistComponentProps {
+ playlist: { songName: string; songDuration: string }[];
+}
+
+export function Playlist({ playlist }: PlaylistComponentProps) {
+ return (
+
+ {playlist.map(
+ (item: { songName: string; songDuration: string }, index) => (
+
+ ),
+ )}
+
+ );
+}
diff --git a/client/src/widgets/albums/ui/Scrollbar.css b/client/src/widgets/albums/ui/Scrollbar.css
new file mode 100644
index 00000000..ff706689
--- /dev/null
+++ b/client/src/widgets/albums/ui/Scrollbar.css
@@ -0,0 +1,3 @@
+::-webkit-scrollbar-thumb {
+ background: #fafafa !important;
+}
diff --git a/client/src/widgets/albums/ui/TrackItem.tsx b/client/src/widgets/albums/ui/TrackItem.tsx
new file mode 100644
index 00000000..8fd2f612
--- /dev/null
+++ b/client/src/widgets/albums/ui/TrackItem.tsx
@@ -0,0 +1,30 @@
+interface TrackItemProps {
+ trackData: { songName: string; songDuration: string };
+ index: number;
+}
+
+export function TrackItem({ trackData, index }: TrackItemProps) {
+ const hour = Math.floor(Number(trackData.songDuration) / 3600);
+ const minute = Math.floor((Number(trackData.songDuration) % 3600) / 60);
+ const second = Math.floor(Number(trackData.songDuration) % 60);
+
+ return (
+
+
+
+
+ {(hour > 0 ? String(hour).padStart(2, '0') + ':' : '') +
+ String(minute).padStart(2, '0') +
+ ':' +
+ String(second).padStart(2, '0')}
+
+
+ );
+}
diff --git a/server/src/comment/comment.repository.ts b/server/src/comment/comment.repository.ts
index 6aef45c6..089d58af 100644
--- a/server/src/comment/comment.repository.ts
+++ b/server/src/comment/comment.repository.ts
@@ -1,34 +1,35 @@
-import { Injectable } from '@nestjs/common';
-import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
-import { Comment } from './comment.entity';
-import { DataSource, Repository } from 'typeorm';
-import { AlbumCommentDto } from './dto/album-comment-response.dto';
-import { plainToInstance } from 'class-transformer';
-
-@Injectable()
-export class CommentRepository {
- constructor(
- @InjectRepository(Comment)
- private readonly commentRepository: Repository,
- @InjectDataSource() private readonly dataSource: DataSource,
- ) {}
-
- async createComment(commentData: {
- albumId: string;
- content: string;
- }): Promise {
- return await this.commentRepository.save(commentData);
- }
-
- async getCommentInfos(albumId: string): Promise {
- const commentInfos = await this.dataSource
- .createQueryBuilder()
- .from(Comment, 'comment')
- .select(['album_id as albumId', 'content'])
- .where('album_id = :albumId', { albumId })
- .orderBy('created_at')
- .getRawMany();
-
- return plainToInstance(AlbumCommentDto, commentInfos);
- }
-}
+import { Injectable } from '@nestjs/common';
+import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
+import { Comment } from './comment.entity';
+import { DataSource, Repository } from 'typeorm';
+import { AlbumCommentDto } from './dto/album-comment-response.dto';
+import { plainToInstance } from 'class-transformer';
+
+@Injectable()
+export class CommentRepository {
+ constructor(
+ @InjectRepository(Comment)
+ private readonly commentRepository: Repository,
+ @InjectDataSource() private readonly dataSource: DataSource,
+ ) {}
+
+ async createComment(commentData: {
+ albumId: string;
+ content: string;
+ }): Promise {
+ return await this.commentRepository.save(commentData);
+ }
+
+ async getCommentInfos(albumId: string): Promise {
+ const commentInfos = await this.dataSource
+ .createQueryBuilder()
+ .from(Comment, 'comment')
+ .select(['album_id as albumId', 'content', 'created_at as createdAt'])
+ .where('album_id = :albumId', { albumId })
+ .orderBy('created_at', 'DESC')
+ .limit(10)
+ .getRawMany();
+
+ return plainToInstance(AlbumCommentDto, commentInfos);
+ }
+}
diff --git a/server/src/comment/dto/album-comment-response.dto.ts b/server/src/comment/dto/album-comment-response.dto.ts
index 8f97af45..473129a4 100644
--- a/server/src/comment/dto/album-comment-response.dto.ts
+++ b/server/src/comment/dto/album-comment-response.dto.ts
@@ -1,20 +1,23 @@
-import { ApiProperty } from '@nestjs/swagger';
-
-export class AlbumCommentResponseDto {
- @ApiProperty({ type: () => AlbumCommentDto, isArray: true })
- result: {
- albumComments: AlbumCommentDto[];
- };
- constructor(albumComments: AlbumCommentDto[]) {
- this.result = {
- albumComments,
- };
- }
-}
-
-export class AlbumCommentDto {
- @ApiProperty()
- albumId: string;
- @ApiProperty()
- content: string;
-}
+import { ApiProperty } from '@nestjs/swagger';
+
+export class AlbumCommentResponseDto {
+ @ApiProperty({ type: () => AlbumCommentDto, isArray: true })
+ result: {
+ albumComments: AlbumCommentDto[];
+ };
+
+ constructor(albumComments: AlbumCommentDto[]) {
+ this.result = {
+ albumComments,
+ };
+ }
+}
+
+export class AlbumCommentDto {
+ @ApiProperty()
+ albumId: string;
+ @ApiProperty()
+ content: string;
+ @ApiProperty()
+ createdAt: Date;
+}
diff --git a/yarn.lock b/yarn.lock
index 60de9610..7c4029ab 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8603,6 +8603,7 @@ __metadata:
"@vitejs/plugin-react": "npm:^4.3.3"
autoprefixer: "npm:^10.4.20"
axios: "npm:^1.7.7"
+ fast-average-color: "npm:^9.4.0"
hls.js: "npm:^1.5.17"
lottie-react: "npm:^2.4.0"
msw: "npm:^2.6.1"
@@ -10976,6 +10977,13 @@ __metadata:
languageName: node
linkType: hard
+"fast-average-color@npm:^9.4.0":
+ version: 9.4.0
+ resolution: "fast-average-color@npm:9.4.0"
+ checksum: 10c0/9031181113356abe240c52f78e908607e3b47dc0121cec3077b3735823951e40f8d6e14eca50d9941e30bcea60e0ed52e36410a8ded0972a89253c3dbefc966d
+ languageName: node
+ linkType: hard
+
"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
version: 3.1.3
resolution: "fast-deep-equal@npm:3.1.3"