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">
-
-
- )
-}
-
-/* ── 이미지 카드 ── */
-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 && ✓}
-
- )}
-
-
-

-
- {!selectMode && (
-
-
-
- )}
-
-
-
-
- )
-}
-
-/* ── 페이지네이션 ── */
-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 && ✓}
+
+ )}
+
+
+

+
+ {!selectMode && (
+
+
+
+ )}
+
+
+
+
+ )
+}
+
+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">
+
+
+ )
+}