feat: 커스텀 툴팁 컴포넌트 추가 및 메뉴 애니메이션 개선
- 마우스 따라다니는 커스텀 Tooltip 컴포넌트 구현 - AdminAlbums 관리 버튼에 툴팁 적용 - 앨범 상세 점3개 메뉴 열기/닫기 애니메이션 추가 - 컨셉 포토에 lazy loading 추가
This commit is contained in:
parent
0a77765a6d
commit
a55d06655f
4 changed files with 124 additions and 42 deletions
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;
|
||||||
|
|
@ -230,30 +230,38 @@ function AlbumDetail() {
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMenu(!showMenu)}
|
onClick={() => setShowMenu(!showMenu)}
|
||||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
className="p-2 hover:bg-gray-100 rounded-full transition-colors relative z-20"
|
||||||
>
|
>
|
||||||
<MoreVertical size={20} className="text-gray-500" />
|
<MoreVertical size={20} className="text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
{showMenu && (
|
<AnimatePresence>
|
||||||
<>
|
{showMenu && (
|
||||||
<div
|
<>
|
||||||
className="fixed inset-0 z-10"
|
<div
|
||||||
onClick={() => setShowMenu(false)}
|
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
|
<motion.div
|
||||||
onClick={() => {
|
initial={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||||
setShowDescriptionModal(true);
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
setShowMenu(false);
|
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||||
}}
|
transition={{ duration: 0.15 }}
|
||||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
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]"
|
||||||
>
|
>
|
||||||
<FileText size={16} />
|
<button
|
||||||
앨범 소개
|
onClick={() => {
|
||||||
</button>
|
setShowDescriptionModal(true);
|
||||||
</div>
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -381,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>
|
||||||
|
|
|
||||||
|
|
@ -969,7 +969,6 @@ 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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