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 ( +
+
+
+ {`${albumDetails.albumName} +

= 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개의 댓글만 조회합니다. + +

+
+

댓글 작성

+ +
+ ); +} 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 ( +
+
+
{index + 1}.
+
{trackData.songName}
+
+ +
+ {(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"