Compare commits
4 commits
1ae01fb2d7
...
27878816b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 27878816b1 | |||
| 1fcb70e2c9 | |||
| a55d06655f | |||
| 0a77765a6d |
8 changed files with 281 additions and 128 deletions
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
70
frontend/src/components/Tooltip.jsx
Normal file
70
frontend/src/components/Tooltip.jsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커스텀 툴팁 컴포넌트
|
||||||
|
* 마우스 커서를 따라다니는 방식
|
||||||
|
* @param {React.ReactNode} children - 툴팁을 표시할 요소
|
||||||
|
* @param {string} text - 툴팁에 표시할 텍스트 (content prop과 호환)
|
||||||
|
* @param {string} content - 툴팁에 표시할 텍스트 (text prop과 호환)
|
||||||
|
*/
|
||||||
|
const Tooltip = ({ children, text, content, className = "" }) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ bottom: 0, left: 0 });
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
|
||||||
|
// text 또는 content prop 사용
|
||||||
|
const tooltipText = text || content;
|
||||||
|
|
||||||
|
const handleMouseEnter = (e) => {
|
||||||
|
// 마우스 커서 위치를 기준으로 툴팁 위치 설정 (커서 위로)
|
||||||
|
setPosition({
|
||||||
|
bottom: window.innerHeight - e.clientY + 10,
|
||||||
|
left: e.clientX
|
||||||
|
});
|
||||||
|
setIsVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
// 마우스 이동 시 툴팁 위치 업데이트
|
||||||
|
setPosition({
|
||||||
|
bottom: window.innerHeight - e.clientY + 10,
|
||||||
|
left: e.clientX
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={triggerRef}
|
||||||
|
className={`inline-flex items-center ${className}`}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={() => setIsVisible(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{isVisible && tooltipText && ReactDOM.createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 5, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 5, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
style={{
|
||||||
|
bottom: position.bottom,
|
||||||
|
left: position.left,
|
||||||
|
}}
|
||||||
|
className="fixed z-[9999] -translate-x-1/2 px-2.5 py-1.5 bg-gray-800 text-white text-xs font-medium rounded-lg shadow-xl pointer-events-none whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{tooltipText}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tooltip;
|
||||||
|
|
@ -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,50 @@ 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 relative z-20"
|
||||||
|
>
|
||||||
|
<MoreVertical size={20} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{showMenu && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
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>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</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 +311,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 && (
|
||||||
|
|
@ -369,6 +389,7 @@ function AlbumDetail() {
|
||||||
<img
|
<img
|
||||||
src={photo.medium_url}
|
src={photo.medium_url}
|
||||||
alt={`컨셉 포토 ${idx + 1}`}
|
alt={`컨셉 포토 ${idx + 1}`}
|
||||||
|
loading="lazy"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -483,6 +504,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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -317,23 +317,37 @@ function AlbumGallery() {
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
/>
|
/>
|
||||||
{/* 컨셉 정보 - 정보가 있을 때만 표시 */}
|
{/* 컨셉 정보 + 멤버 - 하나라도 있으면 표시 */}
|
||||||
{imageLoaded && photos[lightbox.index]?.title && (
|
{imageLoaded && (
|
||||||
<div className="mt-6 flex flex-col items-center gap-2">
|
(() => {
|
||||||
<span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base">
|
const title = photos[lightbox.index]?.title;
|
||||||
{photos[lightbox.index]?.title}
|
const hasValidTitle = title && title.trim() && title !== 'Default';
|
||||||
</span>
|
const members = photos[lightbox.index]?.members;
|
||||||
{/* 멤버가 있고 빈 문자열이 아닐 때만 표시, 쉼표로 분리해서 개별 태그 */}
|
const hasMembers = members && String(members).trim();
|
||||||
{photos[lightbox.index]?.members && String(photos[lightbox.index]?.members).trim() && (
|
|
||||||
<div className="flex items-center gap-2">
|
if (!hasValidTitle && !hasMembers) return null;
|
||||||
{String(photos[lightbox.index]?.members).split(',').map((member, idx) => (
|
|
||||||
<span key={idx} className="px-3 py-1.5 bg-primary/80 rounded-full text-white text-sm">
|
return (
|
||||||
{member.trim()}
|
<div className="mt-6 flex flex-col items-center gap-2">
|
||||||
|
{/* 컨셉명 - 있고 유효할 때만 */}
|
||||||
|
{hasValidTitle && (
|
||||||
|
<span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base">
|
||||||
|
{title}
|
||||||
</span>
|
</span>
|
||||||
))}
|
)}
|
||||||
|
{/* 멤버 - 있으면 항상 표시 */}
|
||||||
|
{hasMembers && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{String(members).split(',').map((member, idx) => (
|
||||||
|
<span key={idx} className="px-3 py-1.5 bg-primary/80 rounded-full text-white text-sm">
|
||||||
|
{member.trim()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
})()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ function Discography() {
|
||||||
<img
|
<img
|
||||||
src={album.cover_url}
|
src={album.cover_url}
|
||||||
alt={album.title}
|
alt={album.title}
|
||||||
|
loading="lazy"
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 will-change-transform"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 will-change-transform"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -371,19 +371,12 @@ function AdminAlbumPhotos() {
|
||||||
|
|
||||||
// 컨셉 포토일 때만 검증
|
// 컨셉 포토일 때만 검증
|
||||||
if (photoType === 'concept') {
|
if (photoType === 'concept') {
|
||||||
// 컨셉명 검증 (각 파일별로)
|
// 개인/유닛인데 멤버 선택 안 한 경우
|
||||||
const missingConcept = pendingFiles.some(f => !f.conceptName.trim());
|
|
||||||
if (missingConcept) {
|
|
||||||
setToast({ message: '모든 사진의 컨셉명을 입력해주세요.', type: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 솔로/유닛인데 멤버 선택 안 한 경우
|
|
||||||
const missingMembers = pendingFiles.some(f =>
|
const missingMembers = pendingFiles.some(f =>
|
||||||
(f.groupType === 'solo' || f.groupType === 'unit') && f.members.length === 0
|
(f.groupType === 'solo' || f.groupType === 'unit') && f.members.length === 0
|
||||||
);
|
);
|
||||||
if (missingMembers) {
|
if (missingMembers) {
|
||||||
setToast({ message: '솔로/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' });
|
setToast({ message: '개인/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -969,21 +962,21 @@ function AdminAlbumPhotos() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-10 h-8 bg-primary/10 text-primary rounded-lg text-center text-sm font-bold border-0 focus:outline-none focus:ring-2 focus:ring-primary [appearance:textfield]"
|
className="w-10 h-8 bg-primary/10 text-primary rounded-lg text-center text-sm font-bold border-0 focus:outline-none focus:ring-2 focus:ring-primary [appearance:textfield]"
|
||||||
title="순서를 직접 입력할 수 있습니다"
|
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
|
|
||||||
|
|
@ -996,7 +989,7 @@ function AdminAlbumPhotos() {
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{[
|
{[
|
||||||
{ value: 'group', icon: Users, label: '단체' },
|
{ value: 'group', icon: Users, label: '단체' },
|
||||||
{ value: 'solo', icon: User, label: '솔로' },
|
{ value: 'solo', icon: User, label: '개인' },
|
||||||
{ value: 'unit', icon: Users2, label: '유닛' },
|
{ value: 'unit', icon: Users2, label: '유닛' },
|
||||||
].map(({ value, icon: Icon, label }) => (
|
].map(({ value, icon: Icon, label }) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -1039,9 +1032,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 && (
|
||||||
|
|
@ -1124,7 +1115,7 @@ function AdminAlbumPhotos() {
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{[
|
{[
|
||||||
{ value: 'group', icon: Users, label: '단체' },
|
{ value: 'group', icon: Users, label: '단체' },
|
||||||
{ value: 'solo', icon: User, label: '솔로' },
|
{ value: 'solo', icon: User, label: '개인' },
|
||||||
{ value: 'unit', icon: Users2, label: '유닛' },
|
{ value: 'unit', icon: Users2, label: '유닛' },
|
||||||
].map(({ value, icon: Icon, label }) => (
|
].map(({ value, icon: Icon, label }) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -1336,7 +1327,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 +1344,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 +1397,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 +1415,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"
|
||||||
/>
|
/>
|
||||||
{/* 호버 시 반투명 오버레이 */}
|
{/* 호버 시 반투명 오버레이 */}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
Home, ChevronRight, LogOut, Calendar, AlertTriangle, X
|
Home, ChevronRight, LogOut, Calendar, AlertTriangle, X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Toast from '../../../components/Toast';
|
import Toast from '../../../components/Toast';
|
||||||
|
import Tooltip from '../../../components/Tooltip';
|
||||||
|
|
||||||
function AdminAlbums() {
|
function AdminAlbums() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -287,27 +288,30 @@ function AdminAlbums() {
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button
|
<Tooltip text="사진 관리" position="top">
|
||||||
onClick={() => navigate(`/admin/albums/${album.id}/photos`)}
|
<button
|
||||||
className="p-2 text-gray-400 hover:text-purple-500 hover:bg-purple-50 rounded-lg transition-colors"
|
onClick={() => navigate(`/admin/albums/${album.id}/photos`)}
|
||||||
title="사진 관리"
|
className="p-2 text-gray-400 hover:text-purple-500 hover:bg-purple-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<Image size={18} />
|
<Image size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
</Tooltip>
|
||||||
onClick={() => navigate(`/admin/albums/${album.id}/edit`)}
|
<Tooltip text="수정" position="top">
|
||||||
className="p-2 text-gray-400 hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
<button
|
||||||
title="수정"
|
onClick={() => navigate(`/admin/albums/${album.id}/edit`)}
|
||||||
>
|
className="p-2 text-gray-400 hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||||
<Edit2 size={18} />
|
>
|
||||||
</button>
|
<Edit2 size={18} />
|
||||||
<button
|
</button>
|
||||||
onClick={() => setDeleteDialog({ show: true, album })}
|
</Tooltip>
|
||||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
<Tooltip text="삭제" position="top">
|
||||||
title="삭제"
|
<button
|
||||||
>
|
onClick={() => setDeleteDialog({ show: true, album })}
|
||||||
<Trash2 size={18} />
|
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
</button>
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue