feat: 앨범 상세 레이아웃 개선, 관리 화면 썸네일 확대 및 최적화

- 앨범 소개를 다이얼로그로 분리 (점3개 메뉴)
- 수록곡 전체 너비로 표시
- 관리 화면 썸네일 180px로 확대
- 메타 영역 고정 높이 (200px)
- lazy loading 및 애니메이션 최적화
This commit is contained in:
caadiq 2026-01-03 10:01:34 +09:00
parent 1ae01fb2d7
commit 0a77765a6d
4 changed files with 143 additions and 80 deletions

View file

@ -14,7 +14,8 @@
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-photo-album": "^3.4.0", "react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3" "react-router-dom": "^6.22.3",
"react-window": "^2.2.3"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
@ -2310,6 +2311,16 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/react-window": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.3.tgz",
"integrity": "sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View file

@ -15,7 +15,8 @@
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-photo-album": "^3.4.0", "react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3" "react-router-dom": "^6.22.3",
"react-window": "^2.2.3"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.3", "@types/react": "^18.3.3",

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Calendar, Music2, Clock, X, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { Calendar, Music2, Clock, X, ChevronLeft, ChevronRight, Download, MoreVertical, FileText } from 'lucide-react';
function AlbumDetail() { function AlbumDetail() {
const { name } = useParams(); const { name } = useParams();
@ -11,6 +11,8 @@ function AlbumDetail() {
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 }); const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 });
const [slideDirection, setSlideDirection] = useState(0); const [slideDirection, setSlideDirection] = useState(0);
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
const [showMenu, setShowMenu] = useState(false);
// //
const goToPrev = useCallback(() => { const goToPrev = useCallback(() => {
@ -202,7 +204,7 @@ function AlbumDetail() {
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className="w-80 h-80 flex-shrink-0 rounded-2xl overflow-hidden shadow-2xl" className="w-80 h-80 flex-shrink-0 rounded-2xl overflow-hidden shadow-lg"
> >
<img <img
src={album.cover_url} src={album.cover_url}
@ -219,9 +221,42 @@ function AlbumDetail() {
className="flex-1 flex flex-col" className="flex-1 flex flex-col"
> >
<div> <div>
<span className="inline-block w-fit px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full mb-3"> <div className="flex items-center justify-between mb-3">
{album.album_type} <span className="inline-block w-fit px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
</span> {album.album_type}
</span>
{/* 점3개 메뉴 - 소개글이 있을 때만 */}
{album.description && (
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
>
<MoreVertical size={20} className="text-gray-500" />
</button>
{showMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowMenu(false)}
/>
<div className="absolute right-0 top-full mt-1 bg-white rounded-xl shadow-lg border border-gray-100 py-1 z-20 min-w-[140px]">
<button
onClick={() => {
setShowDescriptionModal(true);
setShowMenu(false);
}}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<FileText size={16} />
앨범 소개
</button>
</div>
</>
)}
</div>
)}
</div>
<h1 className="text-4xl font-bold mb-3">{album.title}</h1> <h1 className="text-4xl font-bold mb-3">{album.title}</h1>
<div className="flex items-center gap-6 text-gray-500 mb-3"> <div className="flex items-center gap-6 text-gray-500 mb-3">
@ -268,71 +303,48 @@ function AlbumDetail() {
</motion.div> </motion.div>
</div> </div>
{/* 2열 그리드: 소개글 + 트랙 리스트 */} {/* 수록곡 리스트 */}
<div className="grid grid-cols-3 gap-8"> <motion.div
{/* 소개글 */} initial={{ opacity: 0, y: 30 }}
{album.description && ( animate={{ opacity: 1, y: 0 }}
<motion.div transition={{ delay: 0.2, duration: 0.4 }}
initial={{ opacity: 0, y: 30 }} >
animate={{ opacity: 1, y: 0 }} <h2 className="text-xl font-bold mb-4">수록곡</h2>
transition={{ delay: 0.2, duration: 0.4 }} <div className="bg-white rounded-2xl shadow-lg overflow-hidden">
className="col-span-1" {album.tracks?.map((track, index) => (
> <div
<h2 className="text-xl font-bold mb-4">앨범 소개</h2> key={track.id}
<div className="bg-white rounded-2xl shadow-lg overflow-hidden"> className={`group flex items-center gap-4 p-4 hover:bg-primary/5 transition-all duration-200 cursor-pointer ${
<div className="max-h-[460px] overflow-y-auto p-6"> index !== album.tracks.length - 1 ? 'border-b border-gray-100' : ''
<p className="text-gray-600 leading-relaxed text-sm whitespace-pre-line text-justify break-all"> }`}
{album.description} >
</p> {/* 트랙 번호 */}
<div className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 group-hover:bg-primary/10 transition-colors">
<span className="text-gray-500 group-hover:text-primary transition-colors">
{String(track.track_number).padStart(2, '0')}
</span>
</div>
{/* 트랙 정보 */}
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold group-hover:text-primary transition-colors">{track.title}</h3>
{track.is_title_track === 1 && (
<span className="px-2 py-0.5 bg-primary text-white text-xs font-medium rounded-full">
TITLE
</span>
)}
</div>
</div>
{/* 재생 시간 */}
<div className="text-gray-400 tabular-nums text-sm">
{track.duration || '-'}
</div> </div>
</div> </div>
</motion.div> ))}
)} </div>
</motion.div>
{/* 트랙 리스트 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.4 }}
className={album.description ? "col-span-2" : "col-span-3"}
>
<h2 className="text-xl font-bold mb-4">수록곡</h2>
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
{album.tracks?.map((track, index) => (
<div
key={track.id}
className={`group flex items-center gap-4 p-4 hover:bg-primary/5 transition-all duration-200 cursor-pointer ${
index !== album.tracks.length - 1 ? 'border-b border-gray-100' : ''
}`}
>
{/* 트랙 번호 */}
<div className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 group-hover:bg-primary/10 transition-colors">
<span className="text-gray-500 group-hover:text-primary transition-colors">
{String(track.track_number).padStart(2, '0')}
</span>
</div>
{/* 트랙 정보 */}
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold group-hover:text-primary transition-colors">{track.title}</h3>
{track.is_title_track === 1 && (
<span className="px-2 py-0.5 bg-primary text-white text-xs font-medium rounded-full">
TITLE
</span>
)}
</div>
</div>
{/* 재생 시간 */}
<div className="text-gray-400 tabular-nums text-sm">
{track.duration || '-'}
</div>
</div>
))}
</div>
</motion.div>
</div>
{/* 컨셉 포토 섹션 */} {/* 컨셉 포토 섹션 */}
{album.conceptPhotos && Object.keys(album.conceptPhotos).length > 0 && ( {album.conceptPhotos && Object.keys(album.conceptPhotos).length > 0 && (
@ -483,6 +495,44 @@ function AlbumDetail() {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* 앨범 소개 다이얼로그 */}
<AnimatePresence>
{showDescriptionModal && album?.description && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onClick={() => setShowDescriptionModal(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-white rounded-2xl shadow-2xl max-w-xl w-full max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between p-5 border-b border-gray-100">
<h3 className="text-lg font-bold">앨범 소개</h3>
<button
onClick={() => setShowDescriptionModal(false)}
className="p-1.5 hover:bg-gray-100 rounded-full transition-colors"
>
<X size={20} className="text-gray-500" />
</button>
</div>
{/* 내용 */}
<div className="p-6 overflow-y-auto max-h-[60vh]">
<p className="text-gray-600 leading-relaxed whitespace-pre-line text-justify">
{album.description}
</p>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</> </>
); );
} }

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate, Link, useParams } from 'react-router-dom'; import { useNavigate, Link, useParams } from 'react-router-dom';
import { motion, AnimatePresence, Reorder } from 'framer-motion'; import { motion, AnimatePresence, Reorder } from 'framer-motion';
import { import {
@ -973,17 +973,18 @@ function AdminAlbumPhotos() {
/> />
</div> </div>
{/* 썸네일 (작게 축소하여 스크롤 성능 개선) */} {/* 썸네일 (180px로 확대) */}
<img <img
src={file.preview} src={file.preview}
alt={file.filename} alt={file.filename}
draggable="false" draggable="false"
className="w-24 h-24 rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0 select-none" loading="lazy"
className="w-[180px] h-[180px] rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0 select-none"
onClick={() => setPreviewPhoto(file)} onClick={() => setPreviewPhoto(file)}
/> />
{/* 메타 정보 */} {/* 메타 정보 - 고정 높이 */}
<div className="flex-1 space-y-3"> <div className="flex-1 space-y-3 h-[200px] overflow-hidden">
{/* 파일명 */} {/* 파일명 */}
<p className="text-base font-medium text-gray-900 truncate">{file.filename}</p> <p className="text-base font-medium text-gray-900 truncate">{file.filename}</p>
@ -1039,9 +1040,7 @@ function AdminAlbumPhotos() {
))} ))}
</> </>
)} )}
{file.groupType === 'solo' && (
<span className="text-xs text-gray-400 ml-2">( 명만 선택)</span>
)}
</div> </div>
{/* 전 멤버 (다음 줄) */} {/* 전 멤버 (다음 줄) */}
{file.groupType !== 'group' && members.filter(m => m.is_former).length > 0 && ( {file.groupType !== 'group' && members.filter(m => m.is_former).length > 0 && (
@ -1336,7 +1335,7 @@ function AdminAlbumPhotos() {
key={photo.id} key={photo.id}
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2, delay: index * 0.02 }} transition={{ duration: 0.2, delay: index < 20 ? index * 0.02 : 0 }}
className={`relative group aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all duration-200 ${ className={`relative group aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all duration-200 ${
selectedPhotos.includes(photo.id) selectedPhotos.includes(photo.id)
? 'border-primary ring-2 ring-primary/30 scale-[0.98]' ? 'border-primary ring-2 ring-primary/30 scale-[0.98]'
@ -1353,6 +1352,7 @@ function AdminAlbumPhotos() {
<img <img
src={photo.thumb_url || photo.medium_url} src={photo.thumb_url || photo.medium_url}
alt={`사진 ${photo.sort_order}`} alt={`사진 ${photo.sort_order}`}
loading="lazy"
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
/> />
{/* 호버 시 반투명 오버레이 */} {/* 호버 시 반투명 오버레이 */}
@ -1405,7 +1405,7 @@ function AdminAlbumPhotos() {
key={teaser.id} key={teaser.id}
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2, delay: index * 0.02 }} transition={{ duration: 0.2, delay: index < 20 ? index * 0.02 : 0 }}
className={`relative group aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all duration-200 ${ className={`relative group aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all duration-200 ${
selectedPhotos.includes(`teaser-${teaser.id}`) selectedPhotos.includes(`teaser-${teaser.id}`)
? 'border-primary ring-2 ring-primary/30 scale-[0.98]' ? 'border-primary ring-2 ring-primary/30 scale-[0.98]'
@ -1423,6 +1423,7 @@ function AdminAlbumPhotos() {
<img <img
src={teaser.thumb_url || teaser.medium_url} src={teaser.thumb_url || teaser.medium_url}
alt={`티저 ${teaser.sort_order}`} alt={`티저 ${teaser.sort_order}`}
loading="lazy"
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
/> />
{/* 호버 시 반투명 오버레이 */} {/* 호버 시 반투명 오버레이 */}