feat: 콘서트 폼에 굿즈 섹션 추가
- 굿즈 이미지 다중 업로드 및 드래그 순서 변경 - 삭제 시 확인 다이얼로그 표시 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7f3fe7e251
commit
169c584d31
2 changed files with 179 additions and 0 deletions
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { motion, AnimatePresence, Reorder } from "framer-motion";
|
||||||
|
import { Image, Plus, Trash2, GripVertical } from "lucide-react";
|
||||||
|
|
||||||
|
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 굿즈 섹션
|
||||||
|
* - 다수의 굿즈 이미지 업로드
|
||||||
|
* - 드래그로 순서 변경
|
||||||
|
*/
|
||||||
|
function MerchandiseSection({ items, setItems }) {
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
itemId: null,
|
||||||
|
itemName: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 이미지 추가
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
const newItems = files.map((file, i) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
return {
|
||||||
|
id: `md-${Date.now()}-${i}`,
|
||||||
|
file,
|
||||||
|
preview: url,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setItems((prev) => [...prev, ...newItems]);
|
||||||
|
|
||||||
|
// input 초기화
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 삭제 시도
|
||||||
|
const handleRemoveItem = (id) => {
|
||||||
|
const item = items.find((it) => it.id === id);
|
||||||
|
setDeleteConfirm({
|
||||||
|
isOpen: true,
|
||||||
|
itemId: id,
|
||||||
|
itemName: item?.file?.name || "이미지",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 삭제 실행
|
||||||
|
const confirmRemoveItem = () => {
|
||||||
|
if (deleteConfirm.itemId !== null) {
|
||||||
|
setItems((prev) => {
|
||||||
|
const item = prev.find((it) => it.id === deleteConfirm.itemId);
|
||||||
|
if (item?.preview) {
|
||||||
|
URL.revokeObjectURL(item.preview);
|
||||||
|
}
|
||||||
|
return prev.filter((it) => it.id !== deleteConfirm.itemId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDeleteConfirm({ isOpen: false, itemId: null, itemName: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={deleteConfirm.isOpen}
|
||||||
|
onClose={() =>
|
||||||
|
setDeleteConfirm({ isOpen: false, itemId: null, itemName: null })
|
||||||
|
}
|
||||||
|
onConfirm={confirmRemoveItem}
|
||||||
|
title="이미지 삭제"
|
||||||
|
message={
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">{deleteConfirm.itemName}</span>
|
||||||
|
을(를) 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
confirmText="삭제"
|
||||||
|
cancelText="취소"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-6">굿즈</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-gray-200 rounded-xl cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors"
|
||||||
|
>
|
||||||
|
<Image size={36} className="text-gray-300 mb-3" />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
클릭하여 굿즈 이미지를 추가하세요
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-300 mt-1">여러 장 선택 가능</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={items}
|
||||||
|
onReorder={setItems}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Reorder.Item
|
||||||
|
key={item.id}
|
||||||
|
value={item}
|
||||||
|
initial={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||||
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||||
|
className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl"
|
||||||
|
>
|
||||||
|
<div className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 transition-colors">
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-20 h-20 rounded-lg overflow-hidden flex-shrink-0 bg-gray-200">
|
||||||
|
<img
|
||||||
|
src={item.preview}
|
||||||
|
alt={`굿즈 ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-gray-700 truncate">
|
||||||
|
{item.file?.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{index + 1}번째
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveItem(item.id)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</Reorder.Item>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Reorder.Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-400 mt-3">
|
||||||
|
드래그하여 순서를 변경할 수 있습니다. 순서대로 표시됩니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MerchandiseSection;
|
||||||
|
|
@ -11,6 +11,7 @@ import { getMembers } from "@/api/public/members";
|
||||||
|
|
||||||
import ConcertInfoSection from "./ConcertInfoSection";
|
import ConcertInfoSection from "./ConcertInfoSection";
|
||||||
import ScheduleSection from "./ScheduleSection";
|
import ScheduleSection from "./ScheduleSection";
|
||||||
|
import MerchandiseSection from "./MerchandiseSection";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 콘서트 일정 추가 폼
|
* 콘서트 일정 추가 폼
|
||||||
|
|
@ -40,6 +41,9 @@ function ConcertForm() {
|
||||||
{ id: 1, date: "", time: "", venue: null },
|
{ id: 1, date: "", time: "", venue: null },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 굿즈 이미지
|
||||||
|
const [merchandiseItems, setMerchandiseItems] = useState([]);
|
||||||
|
|
||||||
// 로딩 상태
|
// 로딩 상태
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
|
@ -113,6 +117,12 @@ function ConcertForm() {
|
||||||
{/* 공연 일정 */}
|
{/* 공연 일정 */}
|
||||||
<ScheduleSection rounds={rounds} setRounds={setRounds} />
|
<ScheduleSection rounds={rounds} setRounds={setRounds} />
|
||||||
|
|
||||||
|
{/* 굿즈 */}
|
||||||
|
<MerchandiseSection
|
||||||
|
items={merchandiseItems}
|
||||||
|
setItems={setMerchandiseItems}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 버튼 */}
|
{/* 버튼 */}
|
||||||
<div className="flex items-center justify-end gap-4">
|
<div className="flex items-center justify-end gap-4">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue