diff --git a/frontend/src/components/common/Modal.jsx b/frontend/src/components/common/Modal.jsx new file mode 100644 index 0000000..53e2234 --- /dev/null +++ b/frontend/src/components/common/Modal.jsx @@ -0,0 +1,40 @@ +/** + * 관리자 페이지에서 쓰는 일반 모달 래퍼 + * + *
content
+ *
+ */ +export default function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) { + if (!open) return null + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+ {children} +
+
+ ) +} diff --git a/frontend/src/features/admin/pc/AdminImages.jsx b/frontend/src/features/admin/pc/AdminImages.jsx index e0bc397..f080e30 100644 --- a/frontend/src/features/admin/pc/AdminImages.jsx +++ b/frontend/src/features/admin/pc/AdminImages.jsx @@ -3,366 +3,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { api } from '../../../api/client' import ConfirmDialog from '../../../components/common/ConfirmDialog' import { useAuthStore } from '../../../stores/auth' - -/* ── 공용 모달 ── */ -function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) { - if (!open) return null - return ( -
-
e.stopPropagation()} - > -
-

{title}

- -
- {children} -
-
- ) -} - -/* ── 업로드 모달 (다중 지원) ── */ -function UploadModal({ open, onClose, onUpload, uploading, existingNames }) { - const [items, setItems] = useState([]) - const [dragOver, setDragOver] = useState(false) - - useEffect(() => { - if (!open) setItems([]) - }, [open]) - - const addFiles = (fileList) => { - const newItems = [] - Array.from(fileList).forEach((file) => { - if (!file.type.startsWith('image/')) return - const id = `${Date.now()}-${Math.random()}` - const reader = new FileReader() - reader.onload = (e) => { - setItems((prev) => prev.map((it) => it.id === id ? { ...it, preview: e.target.result } : it)) - } - reader.readAsDataURL(file) - newItems.push({ - id, - file, - name: file.name.replace(/\.[^.]+$/, ''), - preview: null, - }) - }) - setItems((prev) => [...prev, ...newItems]) - } - - const updateName = (id, name) => { - setItems((prev) => prev.map((it) => it.id === id ? { ...it, name } : it)) - } - - const removeItem = (id) => { - setItems((prev) => prev.filter((it) => it.id !== id)) - } - - const trimmedNames = items.map((it) => it.name.trim()) - const hasEmpty = trimmedNames.some((n) => !n) - const hasDupExisting = trimmedNames.some((n) => existingNames.has(n)) - const hasDupInList = trimmedNames.some((n, i) => trimmedNames.indexOf(n) !== i) - const canSubmit = items.length > 0 && !hasEmpty && !hasDupExisting && !hasDupInList - - const handleSubmit = async (e) => { - e.preventDefault() - if (!canSubmit) return - await onUpload(items) - } - - return ( - 0 ? ` (${items.length})` : ''}`} maxWidth="max-w-2xl"> -
-
- {/* 파일 추가 영역 */} - - - {/* 선택된 파일 리스트 */} - {items.length > 0 && ( -
- {items.map((item, idx) => { - const trimmed = item.name.trim() - const dupExisting = trimmed && existingNames.has(trimmed) - const dupInList = trimmed && items.some((it, j) => j !== idx && it.name.trim() === trimmed) - const empty = !trimmed - const errorMsg = empty ? '이름을 입력해주세요' - : dupExisting ? '이미 존재하는 이름입니다' - : dupInList ? '같은 이름이 중복됩니다' - : null - - return ( -
-
- {item.preview ? ( - - ) : ( -
- )} -
-
- updateName(item.id, e.target.value)} - className="w-full rounded border px-2 py-1.5 text-sm outline-none" - style={{ - background: 'var(--input-bg)', - borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--input-border)', - color: 'var(--text-strong)', - }} - /> - {errorMsg && ( -
{errorMsg}
- )} -
- -
- ) - })} -
- )} -
- - {/* 버튼 */} -
- - -
- - - ) -} - -/* ── 이미지 카드 ── */ -function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) { - return ( -
selectMode && onToggle(image.id)} - className={`group relative rounded-xl border overflow-hidden ${selectMode ? 'cursor-pointer' : ''}`} - style={{ - borderColor: selected ? 'var(--selected-border)' : 'var(--panel-border)', - background: selected ? 'var(--selected-bg)' : 'var(--panel-bg)', - boxShadow: selected ? '0 0 0 2px var(--ring-info)' : 'var(--panel-shadow)', - }} - > - {selectMode && ( -
- {selected && } -
- )} - -
- {image.name} - - {!selectMode && ( -
- -
- )} -
- -
-
{image.name}
-
-
- ) -} - -/* ── 페이지네이션 ── */ -function Pagination({ page, totalPages, onChange }) { - if (totalPages <= 1) return null - - const pages = [] - const maxButtons = 7 - let start = Math.max(1, page - Math.floor(maxButtons / 2)) - let end = Math.min(totalPages, start + maxButtons - 1) - if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1) - for (let i = start; i <= end; i++) pages.push(i) - - const baseBtn = "min-w-9 h-9 px-3 rounded-lg text-sm flex items-center justify-center border hover:bg-[var(--btn-bg-hover)]" - const btnStyle = { - background: 'var(--btn-bg)', - borderColor: 'var(--btn-border)', - color: 'var(--text-emphasis)', - } - - return ( -
- - - {start > 1 && ( - <> - - {start > 2 && } - - )} - - {pages.map((p) => { - const active = p === page - return ( - - ) - })} - - {end < totalPages && ( - <> - {end < totalPages - 1 && } - - - )} - - -
- ) -} +import ImageCard from './components/ImageCard' +import Pagination from './components/Pagination' +import UploadModal from './components/UploadModal' const PAGE_SIZE = 24 -/* ── 메인 ── */ export default function AdminImages() { const queryClient = useQueryClient() const [page, setPage] = useState(1) diff --git a/frontend/src/features/admin/pc/components/ImageCard.jsx b/frontend/src/features/admin/pc/components/ImageCard.jsx new file mode 100644 index 0000000..d57dc30 --- /dev/null +++ b/frontend/src/features/admin/pc/components/ImageCard.jsx @@ -0,0 +1,68 @@ +import { memo } from 'react' + +function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) { + return ( +
selectMode && onToggle(image.id)} + className={`group relative rounded-xl border overflow-hidden ${selectMode ? 'cursor-pointer' : ''}`} + style={{ + borderColor: selected ? 'var(--selected-border)' : 'var(--panel-border)', + background: selected ? 'var(--selected-bg)' : 'var(--panel-bg)', + boxShadow: selected ? '0 0 0 2px var(--ring-info)' : 'var(--panel-shadow)', + }} + > + {selectMode && ( +
+ {selected && } +
+ )} + +
+ {image.name} + + {!selectMode && ( +
+ +
+ )} +
+ +
+
{image.name}
+
+
+ ) +} + +export default memo(ImageCard) diff --git a/frontend/src/features/admin/pc/components/Pagination.jsx b/frontend/src/features/admin/pc/components/Pagination.jsx new file mode 100644 index 0000000..81f6b11 --- /dev/null +++ b/frontend/src/features/admin/pc/components/Pagination.jsx @@ -0,0 +1,71 @@ +export default function Pagination({ page, totalPages, onChange }) { + if (totalPages <= 1) return null + + const pages = [] + const maxButtons = 7 + let start = Math.max(1, page - Math.floor(maxButtons / 2)) + let end = Math.min(totalPages, start + maxButtons - 1) + if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1) + for (let i = start; i <= end; i++) pages.push(i) + + const baseBtn = "min-w-9 h-9 px-3 rounded-lg text-sm flex items-center justify-center border hover:bg-[var(--btn-bg-hover)]" + const btnStyle = { + background: 'var(--btn-bg)', + borderColor: 'var(--btn-border)', + color: 'var(--text-emphasis)', + } + + return ( +
+ + + {start > 1 && ( + <> + + {start > 2 && } + + )} + + {pages.map((p) => { + const active = p === page + return ( + + ) + })} + + {end < totalPages && ( + <> + {end < totalPages - 1 && } + + + )} + + +
+ ) +} diff --git a/frontend/src/features/admin/pc/components/UploadModal.jsx b/frontend/src/features/admin/pc/components/UploadModal.jsx new file mode 100644 index 0000000..9e65281 --- /dev/null +++ b/frontend/src/features/admin/pc/components/UploadModal.jsx @@ -0,0 +1,179 @@ +import { useState, useEffect } from 'react' +import Modal from '../../../../components/common/Modal' + +export default function UploadModal({ open, onClose, onUpload, uploading, existingNames }) { + const [items, setItems] = useState([]) + const [dragOver, setDragOver] = useState(false) + + useEffect(() => { + if (!open) setItems([]) + }, [open]) + + const addFiles = (fileList) => { + const newItems = [] + Array.from(fileList).forEach((file) => { + if (!file.type.startsWith('image/')) return + const id = `${Date.now()}-${Math.random()}` + const reader = new FileReader() + reader.onload = (e) => { + setItems((prev) => prev.map((it) => it.id === id ? { ...it, preview: e.target.result } : it)) + } + reader.readAsDataURL(file) + newItems.push({ + id, + file, + name: file.name.replace(/\.[^.]+$/, ''), + preview: null, + }) + }) + setItems((prev) => [...prev, ...newItems]) + } + + const updateName = (id, name) => { + setItems((prev) => prev.map((it) => it.id === id ? { ...it, name } : it)) + } + + const removeItem = (id) => { + setItems((prev) => prev.filter((it) => it.id !== id)) + } + + const trimmedNames = items.map((it) => it.name.trim()) + const hasEmpty = trimmedNames.some((n) => !n) + const hasDupExisting = trimmedNames.some((n) => existingNames.has(n)) + const hasDupInList = trimmedNames.some((n, i) => trimmedNames.indexOf(n) !== i) + const canSubmit = items.length > 0 && !hasEmpty && !hasDupExisting && !hasDupInList + + const handleSubmit = async (e) => { + e.preventDefault() + if (!canSubmit) return + await onUpload(items) + } + + return ( + 0 ? ` (${items.length})` : ''}`} maxWidth="max-w-2xl"> +
+
+ + + {items.length > 0 && ( +
+ {items.map((item, idx) => { + const trimmed = item.name.trim() + const dupExisting = trimmed && existingNames.has(trimmed) + const dupInList = trimmed && items.some((it, j) => j !== idx && it.name.trim() === trimmed) + const empty = !trimmed + const errorMsg = empty ? '이름을 입력해주세요' + : dupExisting ? '이미 존재하는 이름입니다' + : dupInList ? '같은 이름이 중복됩니다' + : null + + return ( +
+
+ {item.preview ? ( + + ) : ( +
+ )} +
+
+ updateName(item.id, e.target.value)} + className="w-full rounded border px-2 py-1.5 text-sm outline-none" + style={{ + background: 'var(--input-bg)', + borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--input-border)', + color: 'var(--text-strong)', + }} + /> + {errorMsg && ( +
{errorMsg}
+ )} +
+ +
+ ) + })} +
+ )} +
+ +
+ + +
+ + + ) +}