143 lines
5.8 KiB
React
143 lines
5.8 KiB
React
|
|
/**
|
||
|
|
* 사진/티저 그리드 컴포넌트
|
||
|
|
*/
|
||
|
|
import { memo } from 'react';
|
||
|
|
import { motion } from 'framer-motion';
|
||
|
|
import { Image, Check } from 'lucide-react';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {Object} props
|
||
|
|
* @param {Array} props.items - 사진/티저 목록
|
||
|
|
* @param {Array} props.selectedItems - 선택된 아이템 ID 목록
|
||
|
|
* @param {Function} props.onToggleSelect - 선택 토글 핸들러
|
||
|
|
* @param {'concept'|'teaser'} props.type - 그리드 타입
|
||
|
|
*/
|
||
|
|
const PhotoGrid = memo(function PhotoGrid({ items, selectedItems, onToggleSelect, type }) {
|
||
|
|
if (items.length === 0) {
|
||
|
|
return (
|
||
|
|
<div className="text-center py-16">
|
||
|
|
<Image className="mx-auto text-gray-300 mb-4" size={48} />
|
||
|
|
<p className="text-gray-500">
|
||
|
|
등록된 {type === 'concept' ? '컨셉 포토' : '티저 이미지'}가 없습니다
|
||
|
|
</p>
|
||
|
|
<p className="text-gray-400 text-sm mt-1">
|
||
|
|
업로드 탭에서 {type === 'concept' ? '사진' : '티저'}을 추가하세요
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (type === 'concept') {
|
||
|
|
return (
|
||
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||
|
|
{items.map((photo, index) => (
|
||
|
|
<motion.div
|
||
|
|
key={photo.id}
|
||
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
||
|
|
animate={{ opacity: 1, scale: 1 }}
|
||
|
|
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 ${
|
||
|
|
selectedItems.includes(photo.id)
|
||
|
|
? 'border-primary ring-2 ring-primary/30 scale-[0.98]'
|
||
|
|
: 'border-transparent hover:border-primary/50 hover:shadow-lg'
|
||
|
|
}`}
|
||
|
|
onClick={() => onToggleSelect(photo.id)}
|
||
|
|
>
|
||
|
|
<img
|
||
|
|
src={photo.thumb_url || photo.medium_url}
|
||
|
|
alt={`사진 ${photo.sort_order}`}
|
||
|
|
loading="lazy"
|
||
|
|
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||
|
|
/>
|
||
|
|
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
|
||
|
|
|
||
|
|
<div
|
||
|
|
className={`absolute top-2 left-2 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
||
|
|
selectedItems.includes(photo.id)
|
||
|
|
? 'bg-primary border-primary'
|
||
|
|
: 'bg-white/80 border-gray-300 opacity-0 group-hover:opacity-100'
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{selectedItems.includes(photo.id) && <Check size={14} className="text-white" />}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="absolute top-2 right-2 px-2 py-0.5 bg-black/60 rounded text-white text-xs font-medium">
|
||
|
|
{String(photo.sort_order).padStart(2, '0')}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
|
||
|
|
{photo.concept_name && (
|
||
|
|
<span className="text-white text-xs font-medium truncate block">
|
||
|
|
{photo.concept_name}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</motion.div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Teaser grid
|
||
|
|
return (
|
||
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||
|
|
{items.map((teaser, index) => {
|
||
|
|
const teaserId = `teaser-${teaser.id}`;
|
||
|
|
return (
|
||
|
|
<motion.div
|
||
|
|
key={teaser.id}
|
||
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
||
|
|
animate={{ opacity: 1, scale: 1 }}
|
||
|
|
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 ${
|
||
|
|
selectedItems.includes(teaserId)
|
||
|
|
? 'border-primary ring-2 ring-primary/30 scale-[0.98]'
|
||
|
|
: 'border-transparent hover:border-primary/50 hover:shadow-lg'
|
||
|
|
}`}
|
||
|
|
onClick={() => onToggleSelect(teaserId)}
|
||
|
|
>
|
||
|
|
{teaser.media_type === 'video' ? (
|
||
|
|
<video
|
||
|
|
src={teaser.video_url || teaser.original_url}
|
||
|
|
poster={teaser.thumb_url}
|
||
|
|
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||
|
|
muted
|
||
|
|
loop
|
||
|
|
onMouseEnter={(e) => e.target.play()}
|
||
|
|
onMouseLeave={(e) => {
|
||
|
|
e.target.pause();
|
||
|
|
e.target.currentTime = 0;
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<img
|
||
|
|
src={teaser.thumb_url || teaser.medium_url}
|
||
|
|
alt={`티저 ${teaser.sort_order}`}
|
||
|
|
loading="lazy"
|
||
|
|
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
<div className="absolute inset-0 bg-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
|
||
|
|
|
||
|
|
<div
|
||
|
|
className={`absolute top-2 left-2 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
||
|
|
selectedItems.includes(teaserId)
|
||
|
|
? 'bg-primary border-primary'
|
||
|
|
: 'bg-white/80 border-gray-300 opacity-0 group-hover:opacity-100'
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{selectedItems.includes(teaserId) && <Check size={14} className="text-white" />}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="absolute top-2 right-2 px-2 py-0.5 bg-purple-600/80 rounded text-white text-xs font-medium">
|
||
|
|
{String(teaser.sort_order).padStart(2, '0')}
|
||
|
|
</div>
|
||
|
|
</motion.div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
export default PhotoGrid;
|