Files
sori.studio/pages/admin/media/index.vue
zenn b669d34e83 관리자 검색창 한글 IME 조합 검색 및 모바일 가로 트랙 세로 스크롤 문제 수정
- IME 조합 중에도 검색되는 공통 컴포저블 useImeSearchInput 추가
- 글/멤버/미디어/설정/태그 필터/미디어 모달 검색 IME 조합 대응
- 홈 추천글·페이지 섹션 가로 트랙 touch-action pan-x 제거로 모바일 세로 스크롤 복구
- v1.10.29

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 17:03:31 +09:00

2062 lines
73 KiB
Vue

<script setup>
import {
DEFAULT_UPLOAD_MAX_FILE_COUNT,
DEFAULT_UPLOAD_MAX_TOTAL_SIZE,
formatUploadSizeLimit,
getUploadFilesTotalSize,
normalizeUploadMaxFileCount,
normalizeUploadMaxTotalSize
} from '~/lib/upload-size-limit.js'
import {
getPostBodyPreviewThumbnailUrl,
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl,
withGeneratedImageCacheVersion
} from '~/lib/post-card-thumbnail-url.js'
definePageMeta({
layout: 'admin'
})
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
const MEDIA_THUMBNAIL_ROOT = '썸네일'
/** 관리자 미디어 한 페이지 표시 개수 옵션 */
const mediaPageSizeOptions = [50, 100, 200]
/** 회원 등급 코드 → 미디어 배지 라벨 */
const memberRoleBadgeLabels = {
owner: '소유자',
admin: '관리자',
vip: 'VIP',
member: '멤버'
}
/**
* 논리 폴더 경로가 썸네일 전용 루트인지 확인한다.
* @param {string} folder - 폴더 경로
* @returns {boolean} 썸네일 전용이면 true
*/
const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || String(folder).startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)
const activeTab = ref('library')
const searchText = ref('')
const {
displayValue: searchTextInput,
onInput: onSearchTextInput,
onCompositionEnd: onSearchTextCompositionEnd
} = useImeSearchInput(searchText, { debounce: 250 })
const activeFolder = ref('')
const activeMediaKind = ref('all')
const showUnusedOnly = ref(false)
const isCreateFolderModalOpen = ref(false)
const createFolderModalName = ref('')
const deletingFolder = ref('')
const editingUrl = ref('')
const editingName = ref('')
const editingCategory = ref('')
const deletingUrl = ref('')
const regeneratingThumbnailUrl = ref('')
const regeneratingBodyThumbnailUrl = ref('')
const selectedMediaUrl = ref('')
const selectedMediaUrls = ref([])
const lastSelectedIndex = ref(-1)
const mediaPage = ref(1)
const mediaPageSize = ref(50)
const draggingUrls = ref([])
const uploadInputRef = ref(null)
const uploadingFiles = ref(false)
const deletingSelected = ref(false)
const { data: uploadSettings } = await useFetch('/admin/api/settings', {
key: 'admin-media-upload-settings'
})
const uploadMaxFileCount = computed(() => normalizeUploadMaxFileCount(uploadSettings.value?.uploadMaxFileCount || DEFAULT_UPLOAD_MAX_FILE_COUNT))
const uploadMaxTotalSize = computed(() => normalizeUploadMaxTotalSize(uploadSettings.value?.uploadMaxTotalSize || DEFAULT_UPLOAD_MAX_TOTAL_SIZE))
const { toast, showToast } = useAdminToast()
const { openMenuId, closeMenu } = useAdminRowMenu()
/**
* 폴더 행 메뉴 id
* @param {string} folder - 폴더 경로
* @returns {string}
*/
const getFolderMenuId = (folder) => `folder:${folder}`
const mediaQuery = computed(() => ({
paged: '1',
tab: activeTab.value,
page: mediaPage.value,
pageSize: mediaPageSize.value,
search: searchText.value.trim() || undefined,
folder: activeFolder.value || undefined,
kind: activeMediaKind.value,
unused: showUnusedOnly.value ? '1' : undefined
}))
const { data: mediaResponse, refresh } = await useFetch('/admin/api/media', {
query: mediaQuery,
default: () => ({
items: [],
pagination: {
page: 1,
pageSize: 50,
total: 0,
pageCount: 1
},
counts: {
tabs: {
library: 0,
postThumbnails: 0,
postBodyThumbnails: 0,
thumbnails: 0
},
folders: {},
kinds: {
all: 0,
image: 0,
video: 0,
audio: 0,
file: 0
},
usage: {
all: 0,
unused: 0
}
}
})
})
const mediaItems = computed(() => Array.isArray(mediaResponse.value)
? mediaResponse.value
: (mediaResponse.value?.items || []))
const mediaPagination = computed(() => Array.isArray(mediaResponse.value)
? {
page: 1,
pageSize: mediaPageSize.value,
total: mediaItems.value.length,
pageCount: Math.max(1, Math.ceil(mediaItems.value.length / mediaPageSize.value))
}
: (mediaResponse.value?.pagination || {
page: 1,
pageSize: mediaPageSize.value,
total: 0,
pageCount: 1
}))
const mediaCounts = computed(() => Array.isArray(mediaResponse.value)
? {
tabs: {},
folders: {},
kinds: {},
usage: {}
}
: (mediaResponse.value?.counts || {
tabs: {},
folders: {},
kinds: {},
usage: {}
}))
/**
* 썸네일 디스크 경로 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 회원 아바타 경로이면 true
*/
const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avatars/'))
/**
* 게시물 카드 썸네일 디스크 경로 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 게시물 카드 썸네일이면 true
*/
const isPostCardThumbnailDiskItem = (item) => isPostCardThumbnailUrl(item?.url)
/**
* 페이지 커버 썸네일 디스크 경로 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 페이지 커버 썸네일이면 true
*/
const isPageCoverThumbnailDiskItem = (item) => isPageCoverThumbnailUrl(item?.url)
/**
* 게시물 본문 표시용 이미지 디스크 경로 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 게시물 본문 표시용 이미지이면 true
*/
const isPostBodyThumbnailDiskItem = (item) => isPostBodyThumbnailUrl(item?.url)
/**
* 게시물 본문 파생 이미지 해상도 라벨을 반환한다.
* @param {Object} item - 미디어 항목
* @returns {string} 해상도 라벨
*/
const getPostBodyThumbnailResolutionLabel = (item) => {
const url = String(item?.url || '')
if (url.endsWith('-body-720.webp')) {
return '720px'
}
if (url.endsWith('-body-2560.webp') || url.endsWith('-lightbox.webp')) {
return '2560px'
}
return '1600px'
}
/**
* 미디어 항목이 시스템 파생 파일인지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 시스템 관리 항목이면 true
*/
const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item) || isPageCoverThumbnailDiskItem(item) || isPostBodyThumbnailDiskItem(item)
/**
* 미디어 미리보기 이미지 URL을 반환한다.
* @param {Object} item - 미디어 항목
* @returns {string} 미리보기 URL
*/
const getMediaPreviewUrl = (item) => {
const url = String(item?.url || '')
if (!isPostCardThumbnailDiskItem(item) && !isPageCoverThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)) {
return url
}
return withGeneratedImageCacheVersion(url, `${item?.updatedAt || ''}-${item?.size || ''}`)
}
/**
* 관리자 미디어 그리드에서 사용할 가벼운 이미지 URL을 반환한다.
* @param {Object} item - 미디어 항목
* @returns {string} 그리드 표시 URL
*/
const getMediaGridPreviewUrl = (item) => {
if (getMediaItemKind(item) !== 'image') {
return getMediaPreviewUrl(item)
}
if (isSystemManagedMediaItem(item)) {
return getMediaPreviewUrl(item)
}
if (item?.bodyThumbnailStatus?.hasThumbnail) {
return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(item.url), `${item?.updatedAt || ''}-${item?.size || ''}`)
}
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`)
}
return getMediaPreviewUrl(item)
}
/**
* 그리드용 파생 이미지 로드 실패 시 원본 이미지로 되돌린다.
* @param {Event} event - 이미지 오류 이벤트
* @param {Object} item - 미디어 항목
* @returns {void}
*/
const onMediaGridImageError = (event, item) => {
const image = event.target
if (!(image instanceof HTMLImageElement) || image.dataset.fallbackApplied === 'true') {
return
}
image.dataset.fallbackApplied = 'true'
image.src = getMediaPreviewUrl(item)
}
/**
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 잠금이면 true
*/
const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner)
/**
* 미디어 파일명을 변경할 수 있는지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 변경 가능 여부
*/
const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item) && !isPageCoverThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)
/**
* 미디어 논리 폴더를 변경할 수 있는지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 변경 가능 여부
*/
const canEditMediaCategory = (item) => !isSystemManagedMediaItem(item)
/**
* 미디어 상태 배지 라벨을 반환한다.
* @param {Object} item - 미디어 항목
* @returns {string} 배지 라벨
*/
const getMediaBadgeLabel = (item) => {
if (item?.avatarOwner) {
const roleCode = String(item.avatarOwner.roleCode || 'member')
return memberRoleBadgeLabels[roleCode] || memberRoleBadgeLabels.member
}
if (activeTab.value === 'postThumbnails' && item?.thumbnailStatus?.role === 'card') {
return ''
}
if (activeTab.value === 'postBodyThumbnails' && item?.bodyThumbnailStatus?.role === 'body') {
return ''
}
if (item?.thumbnailStatus?.role === 'card') {
return '카드'
}
if (item?.thumbnailStatus?.role === 'pageCover') {
return '페이지'
}
if (item?.bodyThumbnailStatus?.role === 'body') {
return '본문'
}
if (item?.bodyThumbnailStatus?.isFallbackActive) {
return '원본'
}
if (item?.thumbnailStatus?.isFallbackActive) {
return '원본'
}
return ''
}
/**
* 미디어 상태 배지 클래스를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {string} Tailwind 클래스
*/
const getMediaBadgeClass = (item) => {
if (item?.thumbnailStatus?.isFallbackActive || item?.bodyThumbnailStatus?.isFallbackActive) {
return 'bg-amber-600 text-white'
}
if (item?.thumbnailStatus?.role === 'card') {
return 'bg-sky-700 text-white'
}
if (item?.thumbnailStatus?.role === 'pageCover') {
return 'bg-cyan-800 text-white'
}
if (item?.bodyThumbnailStatus?.role === 'body') {
return 'bg-violet-700 text-white'
}
if (item?.avatarOwner) {
return 'bg-emerald-800 text-white'
}
return 'bg-[#15171a] text-white'
}
/**
* 미디어 사용 개수를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {number} 사용 개수
*/
const getMediaUsageBadgeCount = (item) => {
const usageCount = Array.isArray(item?.usage) ? item.usage.length : 0
const avatarOwnerCount = item?.avatarOwner ? 1 : 0
return usageCount + avatarOwnerCount
}
/**
* 카드 썸네일을 생성할 수 있는 원본 이미지인지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 생성 가능 여부
*/
const canRegeneratePostThumbnail = (item) => (
item?.thumbnailStatus?.role === 'original'
&& item?.thumbnailStatus?.variant !== 'pageCover'
&& (item.thumbnailStatus.isFallbackActive || item.thumbnailStatus.hasThumbnail)
)
/**
* 페이지 커버 썸네일을 생성할 수 있는 원본 이미지인지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 생성 가능 여부
*/
const canRegeneratePageCoverThumbnail = (item) => (
item?.thumbnailStatus?.role === 'original'
&& item?.thumbnailStatus?.variant === 'pageCover'
&& (item.thumbnailStatus.isFallbackActive || item.thumbnailStatus.hasThumbnail)
)
/**
* 본문 표시용 이미지를 생성할 수 있는 원본 이미지인지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 생성 가능 여부
*/
const canRegeneratePostBodyThumbnail = (item) => (
item?.bodyThumbnailStatus?.role === 'original'
&& (item.bodyThumbnailStatus.isFallbackActive || item.bodyThumbnailStatus.hasThumbnail)
)
/**
* 미디어 항목 종류를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
*/
const getMediaItemKind = (item) => item?.kind || 'image'
const systemManagedTab = computed(() => ['thumbnails', 'postThumbnails', 'postBodyThumbnails'].includes(activeTab.value))
const activeSystemMediaCount = computed(() => {
if (activeTab.value === 'postThumbnails') {
return mediaCounts.value.tabs?.postThumbnails || 0
}
if (activeTab.value === 'postBodyThumbnails') {
return mediaCounts.value.tabs?.postBodyThumbnails || 0
}
return mediaCounts.value.tabs?.thumbnails || 0
})
const activeSystemMediaTitle = computed(() => {
if (activeTab.value === 'postThumbnails') {
return '카드 썸네일'
}
if (activeTab.value === 'postBodyThumbnails') {
return '본문 이미지'
}
return '프로필 이미지'
})
const activeSystemMediaHint = computed(() => {
if (activeTab.value === 'postThumbnails') {
return '게시물 목록 카드와 홈 Pages 진열장에서 쓰는 자동 생성 이미지입니다. 원본 이미지가 사용 중이고 이 썸네일 파일이 있으면 공개 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 썸네일은 사용 중으로 표시되며, 썸네일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.'
}
if (activeTab.value === 'postBodyThumbnails') {
return '게시물 본문을 빠르게 보여주기 위해 원본 이미지에서 자동 생성한 WebP 이미지입니다. 본문에는 720px·1600px 파일을 우선 표시하고, 크게 보기에는 2560px 파일을 우선 사용합니다. 원본과 연결되어 있어 이름 변경과 폴더 이동은 제한됩니다.'
}
return '회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제해도 디스크 파일은 삭제되지 않으며 이 목록에 남습니다. 목록이 바로 안 바뀌면 페이지를 새로고침하세요. 관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다.'
})
const mediaKindFilterOptions = computed(() => {
const countByKind = mediaCounts.value.kinds || {}
return [
{ id: 'all', label: '전체', count: countByKind.all || 0 },
{ id: 'image', label: '이미지', count: countByKind.image || 0 },
{ id: 'video', label: '영상', count: countByKind.video || 0 },
{ id: 'audio', label: '음악', count: countByKind.audio || 0 },
{ id: 'file', label: '파일', count: countByKind.file || 0 }
]
})
const mediaUsageFilterOptions = computed(() => {
const usageCounts = mediaCounts.value.usage || {}
return [
{ id: 'all', label: '전체', count: usageCounts.all || 0, unusedOnly: false },
{ id: 'unused', label: '미사용', count: usageCounts.unused || 0, unusedOnly: true }
]
})
const visibleMediaUsageFilterOptions = computed(() => activeTab.value === 'library'
? mediaUsageFilterOptions.value.filter((option) => option.id !== 'all')
: mediaUsageFilterOptions.value)
/**
* 상단 탭 전환 시 목록 상태를 초기화한다.
* @param {'library' | 'postThumbnails' | 'postBodyThumbnails' | 'thumbnails'} tab - 선택 탭
* @returns {void}
*/
const setActiveTab = (tab) => {
if (activeTab.value === tab) {
return
}
activeTab.value = tab
activeFolder.value = ''
searchText.value = ''
activeMediaKind.value = 'all'
showUnusedOnly.value = false
clearMediaSelection()
closeMediaDetail()
}
/**
* 미디어 종류 필터를 선택한다.
* @param {'all'|'image'|'video'|'audio'|'file'} kind - 선택할 미디어 종류
* @returns {void}
*/
const setMediaKindFilter = (kind) => {
activeMediaKind.value = kind
clearMediaSelection()
}
/**
* 미디어 사용 상태 필터를 선택한다.
* @param {boolean} unusedOnly - 미사용 항목만 볼지 여부
* @returns {void}
*/
const setMediaUsageFilter = (unusedOnly) => {
if (showUnusedOnly.value === unusedOnly) {
if (unusedOnly) {
showUnusedOnly.value = false
clearMediaSelection()
}
return
}
showUnusedOnly.value = unusedOnly
clearMediaSelection()
}
/**
* ISO 시각을 짧은 로캘 문자열로 표시한다.
* @param {string | null} iso - ISO 시각
* @returns {string} 표시 문자열
*/
const formatDateTime = (iso) => {
if (!iso) {
return '—'
}
try {
return new Date(iso).toLocaleString('ko-KR', {
dateStyle: 'medium',
timeStyle: 'short'
})
} catch {
return '—'
}
}
/**
* 상세 모달에서 선택된 미디어를 브라우저로 내려받는다.
* @returns {void}
*/
const downloadSelectedMedia = () => {
const item = selectedMedia.value
if (!item?.url || !import.meta.client) {
return
}
const anchor = document.createElement('a')
anchor.href = item.url
anchor.download = item.name || 'image'
anchor.rel = 'noopener noreferrer'
anchor.target = '_blank'
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
}
const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', {
default: () => ['미분류']
})
const selectedMedia = computed(() => mediaItems.value.find((item) => item.url === selectedMediaUrl.value) || null)
const normalizedFolders = computed(() => {
const folderSet = new Set(['미분류'])
const folderCounts = mediaCounts.value.folders || {}
mediaFolders.value.forEach((folder) => {
String(folder || '').split('/').filter(Boolean).reduce((parentPath, segment) => {
const nextPath = parentPath ? `${parentPath}/${segment}` : segment
folderSet.add(nextPath)
return nextPath
}, '')
})
Object.keys(folderCounts).forEach((folder) => {
String(folder || '미분류').split('/').filter(Boolean).reduce((parentPath, segment) => {
const nextPath = parentPath ? `${parentPath}/${segment}` : segment
folderSet.add(nextPath)
return nextPath
}, '')
})
return [...folderSet].sort((left, right) => left.localeCompare(right))
})
const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts, folder) => {
counts[folder] = mediaCounts.value.folders?.[folder] || 0
return counts
}, {}))
const filteredMediaItems = computed(() => mediaItems.value)
const filteredMediaUrls = computed(() => filteredMediaItems.value.map((item) => item.url))
const mediaPageCount = computed(() => Math.max(1, Number(mediaPagination.value.pageCount) || 1))
const mediaPageTotal = computed(() => Number(mediaPagination.value.total) || 0)
const mediaPageStartIndex = computed(() => mediaPageTotal.value
? ((Number(mediaPagination.value.page) || mediaPage.value) - 1) * mediaPageSize.value
: 0)
const mediaPageDisplayStart = computed(() => mediaPageTotal.value ? mediaPageStartIndex.value + 1 : 0)
const mediaPageEndIndex = computed(() => Math.min(mediaPageStartIndex.value + filteredMediaItems.value.length, mediaPageTotal.value))
const paginatedMediaItems = computed(() => filteredMediaItems.value)
const filteredSelectableMediaItems = computed(() => paginatedMediaItems.value.filter((item) => !isMediaItemLocked(item)))
const filteredSelectableMediaUrls = computed(() => filteredSelectableMediaItems.value.map((item) => item.url))
const isAllFilteredMediaSelected = computed(() => {
if (!filteredSelectableMediaUrls.value.length) {
return false
}
return filteredSelectableMediaUrls.value.every((url) => selectedMediaUrls.value.includes(url))
})
const mediaPaginationItems = computed(() => {
const total = mediaPageCount.value
const current = mediaPage.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const pages = new Set([1, total, current - 1, current, current + 1])
if (current <= 4) {
pages.add(2)
pages.add(3)
pages.add(4)
pages.add(5)
}
if (current >= total - 3) {
pages.add(total - 4)
pages.add(total - 3)
pages.add(total - 2)
pages.add(total - 1)
}
const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right)
return orderedPages.reduce((items, page, index) => {
const previousPage = orderedPages[index - 1]
if (previousPage && page - previousPage > 1) {
items.push(`ellipsis-${previousPage}-${page}`)
}
items.push(page)
return items
}, [])
})
/**
* 관리자 미디어 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {void}
*/
const setMediaPage = (page) => {
mediaPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPageCount.value)
clearMediaSelection()
closeMediaDetail()
}
/**
* 관리자 미디어 페이지 표시 개수를 변경한다.
* @param {Event} event - select 변경 이벤트
* @returns {void}
*/
const setMediaPageSize = (event) => {
mediaPageSize.value = Number(event.target.value) || 50
mediaPage.value = 1
clearMediaSelection()
closeMediaDetail()
}
const selectedDeletableMediaItems = computed(() => {
const selectedUrlSet = new Set(selectedMediaUrls.value)
return filteredMediaItems.value.filter((item) => selectedUrlSet.has(item.url) && !isMediaItemLocked(item))
})
/**
* 파일 크기 표시 문자열 생성
* @param {number} size - 파일 크기
* @returns {string} 표시 문자열
*/
const formatFileSize = (size) => {
if (size < 1024) {
return `${size} B`
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`
}
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
/**
* 폴더 경로의 표시 이름 조회
* @param {string} folder - 폴더 경로
* @returns {string} 표시 이름
*/
const getFolderName = (folder) => folder.split('/').filter(Boolean).pop() || '미분류'
/**
* 폴더 깊이 조회
* @param {string} folder - 폴더 경로
* @returns {number} 폴더 깊이
*/
const getFolderDepth = (folder) => Math.max(folder.split('/').filter(Boolean).length - 1, 0)
/**
* 미디어 선택 여부 확인
* @param {Object} item - 미디어 항목
* @returns {boolean} 선택 여부
*/
const isMediaSelected = (item) => selectedMediaUrls.value.includes(item.url)
/**
* 폴더 선택
* @param {string} folder - 폴더 경로
* @returns {void}
*/
const selectFolder = (folder) => {
activeFolder.value = folder
activeMediaKind.value = 'all'
showUnusedOnly.value = false
selectedMediaUrls.value = []
lastSelectedIndex.value = -1
}
/**
* 체크박스로 미디어 선택 토글(Shift 시 범위 추가 선택)
* @param {Object} item - 미디어 항목
* @param {number} index - 필터 목록 내 순서
* @param {MouseEvent} event - 클릭 이벤트
* @returns {void}
*/
const toggleMediaSelection = (item, index, event) => {
if (event.shiftKey && lastSelectedIndex.value >= 0) {
const startIndex = Math.min(lastSelectedIndex.value, index)
const endIndex = Math.max(lastSelectedIndex.value, index)
const rangeUrls = paginatedMediaItems.value.slice(startIndex, endIndex + 1).map((mediaItem) => mediaItem.url)
const merged = new Set([...selectedMediaUrls.value, ...rangeUrls])
selectedMediaUrls.value = [...merged]
lastSelectedIndex.value = index
return
}
selectedMediaUrls.value = isMediaSelected(item)
? selectedMediaUrls.value.filter((url) => url !== item.url)
: [...selectedMediaUrls.value, item.url]
lastSelectedIndex.value = index
}
/**
* 선택된 미디어 해제
* @returns {void}
*/
const clearMediaSelection = () => {
selectedMediaUrls.value = []
lastSelectedIndex.value = -1
}
/**
* 현재 페이지에 표시된 미디어 전체 선택을 토글한다.
* @returns {void}
*/
const toggleFilteredMediaSelection = () => {
if (!filteredSelectableMediaUrls.value.length) {
return
}
if (isAllFilteredMediaSelected.value) {
const filteredUrlSet = new Set(filteredSelectableMediaUrls.value)
selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => !filteredUrlSet.has(url))
lastSelectedIndex.value = -1
return
}
selectedMediaUrls.value = [...new Set([
...selectedMediaUrls.value,
...filteredSelectableMediaUrls.value
])]
lastSelectedIndex.value = paginatedMediaItems.value.length - 1
}
/**
* 파일 직접 업로드 선택창을 연다.
* @returns {void}
*/
const openUploadFilePicker = () => {
if (activeTab.value !== 'library') {
return
}
uploadInputRef.value?.click()
}
/**
* 선택된 파일을 미디어 라이브러리에 업로드한다.
* @param {Event} event - 파일 입력 변경 이벤트
* @returns {Promise<void>}
*/
const uploadSelectedFiles = async (event) => {
if (activeTab.value !== 'library') {
return
}
const input = event.target
if (!(input instanceof HTMLInputElement) || !input.files?.length) {
return
}
const files = Array.from(input.files)
if (files.length > uploadMaxFileCount.value) {
showToast('error', `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount.value}개입니다.`)
input.value = ''
return
}
if (getUploadFilesTotalSize(files) > uploadMaxTotalSize.value) {
showToast('error', `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize.value)})`)
input.value = ''
return
}
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file)
})
uploadingFiles.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
const uploadedUrls = Array.isArray(result?.files)
? result.files.map((file) => file.url).filter(Boolean)
: []
if (activeFolder.value && uploadedUrls.length) {
await $fetch('/admin/api/media', {
method: 'PUT',
body: {
urls: uploadedUrls,
category: activeFolder.value
}
})
}
await Promise.all([
refresh(),
refreshMediaFolders()
])
selectedMediaUrls.value = uploadedUrls
showToast('success', `${files.length}개 파일을 추가했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '파일을 업로드하지 못했습니다.')
} finally {
uploadingFiles.value = false
input.value = ''
}
}
/**
* 미디어 상세 모달 열기
* @param {Object} item - 미디어 항목
* @returns {void}
*/
const openMediaDetail = (item) => {
selectedMediaUrl.value = item.url
editingUrl.value = item.url
editingName.value = item.title
editingCategory.value = item.category
}
/**
* 미디어 상세 모달 닫기
* @returns {void}
*/
const closeMediaDetail = () => {
selectedMediaUrl.value = ''
cancelRename()
}
/**
* 미디어 파일명 변경 취소
* @returns {void}
*/
const cancelRename = () => {
editingUrl.value = ''
editingName.value = ''
}
/**
* 폴더 추가 모달을 연다
* @returns {void}
*/
const openCreateFolderModal = () => {
createFolderModalName.value = ''
isCreateFolderModalOpen.value = true
}
/**
* 폴더 추가 모달을 닫는다
* @returns {void}
*/
const closeCreateFolderModal = () => {
isCreateFolderModalOpen.value = false
createFolderModalName.value = ''
}
/**
* 미디어 폴더 생성(모달에서 확인 시)
* @returns {Promise<void>}
*/
const submitCreateFolderModal = async () => {
if (activeTab.value !== 'library') {
return
}
const folderName = createFolderModalName.value.trim()
if (!folderName) {
return
}
try {
const folderPath = activeFolder.value ? `${activeFolder.value}/${folderName}` : folderName
const createdFolder = await $fetch('/admin/api/media-folders', {
method: 'POST',
body: {
path: folderPath
}
})
closeCreateFolderModal()
activeFolder.value = createdFolder.path
await refreshMediaFolders()
} catch (error) {
showToast('error', error?.data?.message || '폴더를 만들지 못했습니다.')
}
}
/**
* 미디어 폴더 삭제
* @param {string} folder - 삭제할 폴더 경로
* @returns {Promise<void>}
*/
const removeMediaFolder = async (folder) => {
closeMenu()
if (!folder || folder === '미분류' || folder === MEDIA_THUMBNAIL_ROOT || folder.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
return
}
if (!confirm(`"${folder}" 폴더를 삭제할까요? 이 폴더(및 하위 경로)에 속한 미디어는 모두 "미분류"로 옮겨집니다.`)) {
return
}
deletingFolder.value = folder
try {
await $fetch('/admin/api/media-folders', {
method: 'DELETE',
body: {
path: folder
}
})
if (activeFolder.value === folder || activeFolder.value.startsWith(`${folder}/`)) {
activeFolder.value = ''
}
selectedMediaUrls.value = []
lastSelectedIndex.value = -1
await Promise.all([
refresh(),
refreshMediaFolders()
])
} catch (error) {
showToast('error', error?.data?.message || '폴더를 삭제하지 못했습니다.')
} finally {
deletingFolder.value = ''
}
}
/**
* 선택한 미디어를 폴더로 이동
* @param {string} folder - 폴더 경로
* @param {Array<string>} urls - 이동할 미디어 URL 목록
* @returns {Promise<void>}
*/
const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
if (activeTab.value !== 'library') {
return
}
const targetUrls = [...new Set(urls.filter(Boolean))]
if (!targetUrls.length) {
return
}
try {
await $fetch('/admin/api/media', {
method: 'PUT',
body: {
urls: targetUrls,
category: folder || '미분류'
}
})
await Promise.all([
refresh(),
refreshMediaFolders()
])
activeFolder.value = folder || '미분류'
clearMediaSelection()
} catch (error) {
showToast('error', error?.data?.message || '미디어 폴더를 변경하지 못했습니다.')
}
}
/**
* 미디어 드래그 시작
* @param {DragEvent} event - 드래그 이벤트
* @param {Object} item - 미디어 항목
* @returns {void}
*/
const startMediaDrag = (event, item) => {
if (activeTab.value !== 'library') {
return
}
if (!isMediaSelected(item)) {
selectedMediaUrls.value = [item.url]
}
draggingUrls.value = [...selectedMediaUrls.value]
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', draggingUrls.value.join('\n'))
}
}
/**
* 폴더로 미디어 드롭
* @param {string} folder - 폴더 경로
* @returns {Promise<void>}
*/
const dropMediaOnFolder = async (folder) => {
if (activeTab.value !== 'library') {
draggingUrls.value = []
return
}
const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value
draggingUrls.value = []
await moveMediaToFolder(folder, urls)
}
/**
* 미디어 카테고리 저장
* @returns {Promise<void>} 저장 결과
*/
const saveMediaCategory = async () => {
if (!canEditMediaCategory(selectedMedia.value)) {
return
}
try {
await $fetch('/admin/api/media', {
method: 'PUT',
body: {
url: selectedMedia.value.url,
category: editingCategory.value
}
})
await Promise.all([
refresh(),
refreshMediaFolders()
])
} catch (error) {
showToast('error', error?.data?.message || '카테고리를 저장하지 못했습니다.')
}
}
/**
* 미디어 파일명 변경
* @returns {Promise<void>}
*/
const renameMedia = async () => {
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
if (editingItem && !canRenameMediaItem(editingItem)) {
showToast('error', '사용 중인 미디어 또는 카드 썸네일은 파일명을 바꿀 수 없습니다.')
return
}
try {
const renamedItem = await $fetch('/admin/api/media', {
method: 'PUT',
body: {
url: editingUrl.value,
name: editingName.value
}
})
cancelRename()
await refresh()
selectedMediaUrl.value = renamedItem.url
} catch (error) {
showToast('error', error?.data?.message || '파일명을 변경하지 못했습니다.')
}
}
/**
* 미디어 삭제
* @param {Object} item - 미디어 항목
* @returns {Promise<void>}
*/
const deleteMedia = async (item) => {
if (isMediaItemLocked(item)) {
showToast('error', '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 삭제할 수 없습니다.')
return
}
if (!confirm(`"${item.name}" 파일을 삭제할까요?`)) {
return
}
deletingUrl.value = item.url
try {
await $fetch('/admin/api/media', {
method: 'DELETE',
body: {
url: item.url
}
})
closeMediaDetail()
await refresh()
} catch (error) {
showToast('error', error?.data?.message || '파일을 삭제하지 못했습니다.')
} finally {
deletingUrl.value = ''
}
}
/**
* 선택한 원본 이미지의 카드 썸네일을 다시 생성한다.
* @param {Object} item - 미디어 항목
* @returns {Promise<void>}
*/
const regeneratePostThumbnail = async (item) => {
if (!canRegeneratePostThumbnail(item)) {
return
}
regeneratingThumbnailUrl.value = item.url
try {
await $fetch('/admin/api/media/thumbnail', {
method: 'POST',
body: {
url: item.url
}
})
await refresh()
selectedMediaUrl.value = item.url
showToast('success', '카드 썸네일을 생성했습니다.')
} catch (error) {
showToast('error', error?.data?.message || '카드 썸네일을 생성하지 못했습니다.')
} finally {
regeneratingThumbnailUrl.value = ''
}
}
/**
* 선택한 원본 이미지의 페이지 커버 썸네일을 다시 생성한다.
* @param {Object} item - 미디어 항목
* @returns {Promise<void>}
*/
const regeneratePageCoverThumbnail = async (item) => {
if (!canRegeneratePageCoverThumbnail(item)) {
return
}
regeneratingThumbnailUrl.value = item.url
try {
await $fetch('/admin/api/media/thumbnail', {
method: 'POST',
body: {
url: item.url,
kind: 'pageCover'
}
})
await refresh()
selectedMediaUrl.value = item.url
showToast('success', '페이지 커버 썸네일을 생성했습니다.')
} catch (error) {
showToast('error', error?.data?.message || '페이지 커버 썸네일을 생성하지 못했습니다.')
} finally {
regeneratingThumbnailUrl.value = ''
}
}
/**
* 선택한 원본 이미지의 본문 표시용 이미지를 다시 생성한다.
* @param {Object} item - 미디어 항목
* @returns {Promise<void>}
*/
const regeneratePostBodyThumbnail = async (item) => {
if (!canRegeneratePostBodyThumbnail(item)) {
return
}
regeneratingBodyThumbnailUrl.value = item.url
try {
await $fetch('/admin/api/media/thumbnail', {
method: 'POST',
body: {
url: item.url,
kind: 'body'
}
})
await refresh()
selectedMediaUrl.value = item.url
showToast('success', '본문 이미지를 생성했습니다.')
} catch (error) {
showToast('error', error?.data?.message || '본문 이미지를 생성하지 못했습니다.')
} finally {
regeneratingBodyThumbnailUrl.value = ''
}
}
/**
* 선택한 미디어 중 삭제 가능한 항목을 한 번에 삭제한다.
* @returns {Promise<void>}
*/
const deleteSelectedMedia = async () => {
const items = selectedDeletableMediaItems.value
const lockedCount = selectedMediaUrls.value.length - items.length
if (!items.length) {
showToast('error', '삭제할 수 있는 선택 항목이 없습니다.')
return
}
const confirmMessage = lockedCount > 0
? `${items.length}개 파일을 삭제할까요? 잠긴 ${lockedCount}개 항목은 제외됩니다.`
: `${items.length}개 파일을 삭제할까요?`
if (!confirm(confirmMessage)) {
return
}
deletingSelected.value = true
try {
for (const item of items) {
await $fetch('/admin/api/media', {
method: 'DELETE',
body: {
url: item.url
}
})
}
closeMediaDetail()
clearMediaSelection()
await Promise.all([
refresh(),
refreshMediaFolders()
])
showToast('success', `${items.length}개 파일을 삭제했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '선택한 파일을 삭제하지 못했습니다.')
} finally {
deletingSelected.value = false
}
}
watch(filteredMediaUrls, (urls) => {
const visibleUrlSet = new Set(urls)
selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => visibleUrlSet.has(url))
if (!selectedMediaUrls.value.length) {
lastSelectedIndex.value = -1
}
})
watch([activeTab, activeFolder, activeMediaKind, showUnusedOnly, searchText], () => {
mediaPage.value = 1
clearMediaSelection()
closeMediaDetail()
})
watch(mediaPageCount, (pageCount) => {
if (mediaPage.value > pageCount) {
mediaPage.value = pageCount
clearMediaSelection()
closeMediaDetail()
}
})
watch(() => mediaPagination.value.page, (page) => {
const normalizedPage = Number(page) || 1
if (normalizedPage !== mediaPage.value) {
mediaPage.value = normalizedPage
}
})
</script>
<template>
<section class="admin-media bg-paper p-6">
<div class="admin-media__header flex flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<p class="admin-media__eyebrow text-xs font-semibold uppercase text-muted">
Media
</p>
<h1 class="admin-media__title mt-2 text-3xl font-semibold">
미디어
</h1>
<div class="admin-media__tabs mt-3 inline-flex rounded-lg border border-line bg-surface p-0.5">
<button
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
:class="activeTab === 'library' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
type="button"
@click="setActiveTab('library')"
>
미디어 라이브러리
</button>
<button
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
:class="activeTab === 'postThumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
type="button"
@click="setActiveTab('postThumbnails')"
>
카드 썸네일
</button>
<button
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
:class="activeTab === 'postBodyThumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
type="button"
@click="setActiveTab('postBodyThumbnails')"
>
본문 이미지
</button>
<button
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
:class="activeTab === 'thumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
type="button"
@click="setActiveTab('thumbnails')"
>
프로필 이미지
</button>
</div>
</div>
<div class="admin-media__header-actions flex min-w-0 flex-wrap items-center gap-2">
<label class="admin-media__search relative">
<span class="sr-only">미디어 검색</span>
<svg class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#8e9cac]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
<input
:value="searchTextInput"
class="admin-media__search-input h-10 w-full rounded border border-line bg-white pl-9 pr-3 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] hover:border-[#c8ced3] focus:border-[#8e9cac] md:w-72"
type="search"
placeholder="미디어 검색"
@input="onSearchTextInput"
@compositionupdate="onSearchTextInput"
@compositionend="onSearchTextCompositionEnd"
>
</label>
<button
v-if="activeTab === 'library'"
class="admin-media__upload-button h-10 shrink-0 rounded bg-[#15171a] px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="uploadingFiles"
@click="openUploadFilePicker"
>
{{ uploadingFiles ? '추가 중' : '파일 추가' }}
</button>
<input
ref="uploadInputRef"
class="hidden"
type="file"
multiple
@change="uploadSelectedFiles"
>
</div>
</div>
<div class="admin-media__filters flex flex-wrap gap-1.5">
<template v-if="activeTab === 'library'">
<button
v-for="option in mediaKindFilterOptions"
:key="option.id"
class="admin-media__kind-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="activeMediaKind === option.id ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
type="button"
@click="setMediaKindFilter(option.id)"
>
{{ option.label }}
<span class="ml-1 opacity-70">{{ option.count }}</span>
</button>
</template>
<button
v-for="option in visibleMediaUsageFilterOptions"
:key="`usage-${option.id}`"
class="admin-media__usage-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="showUnusedOnly === option.unusedOnly ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
type="button"
:aria-pressed="showUnusedOnly === option.unusedOnly"
@click="setMediaUsageFilter(option.unusedOnly)"
>
{{ option.label }}
<span class="ml-1 opacity-70">{{ option.count }}</span>
</button>
</div>
</div>
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside
v-if="activeTab === 'library'"
class="admin-media__folders rounded border border-line bg-white p-3"
>
<button
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold hover:bg-surface"
:class="!activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
type="button"
@click="selectFolder('')"
@dragover.prevent
@drop.prevent="dropMediaOnFolder('미분류')"
>
<span>전체 미디어</span>
<span>{{ mediaCounts.tabs?.library || 0 }}</span>
</button>
<div class="admin-media__folder-list mt-3 grid gap-1">
<div
v-for="folder in normalizedFolders"
:key="folder"
class="admin-media__folder-row flex items-stretch rounded"
:class="activeFolder === folder ? 'bg-[#15171a] text-white' : 'text-ink hover:bg-surface'"
>
<button
class="admin-media__folder-button flex min-w-0 flex-1 items-center justify-between rounded py-2 pr-3 text-left text-sm"
:style="{ paddingLeft: `${12 + getFolderDepth(folder) * 14}px` }"
type="button"
@click="selectFolder(folder)"
@dragover.prevent
@drop.prevent="dropMediaOnFolder(folder)"
>
<span class="admin-media__folder-name min-w-0 truncate">{{ getFolderName(folder) }}</span>
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span>
</button>
<AdminRowMoreMenu
v-if="folder !== '미분류' && !isThumbnailFolderPath(folder)"
v-model:open-menu-id="openMenuId"
:item-id="getFolderMenuId(folder)"
menu-label="폴더 메뉴"
size="sm"
class="mr-0.5 shrink-0"
:inverse="activeFolder === folder"
:busy="deletingFolder === folder"
>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
:disabled="deletingFolder === folder"
@click="removeMediaFolder(folder)"
>
폴더 삭제
</button>
</AdminRowMoreMenu>
</div>
</div>
<div v-if="activeTab === 'library'" class="admin-media__folder-actions mt-4 border-t border-line pt-4">
<button
class="admin-media__folder-add w-full rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white"
type="button"
@click="openCreateFolderModal"
>
폴더 추가
</button>
</div>
</aside>
<aside
v-else
class="admin-media__folders admin-media__folders--thumbnails rounded border border-line bg-white p-3"
>
<button
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold bg-[#15171a] text-white hover:bg-[#15171a]"
type="button"
@click="selectFolder('')"
>
<span>{{ activeSystemMediaTitle }}</span>
<span>{{ activeSystemMediaCount }}</span>
</button>
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
{{ activeSystemMediaHint }}
</p>
</aside>
<div class="admin-media__content min-w-0">
<div class="admin-media__toolbar flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="admin-media__folder-title text-lg font-semibold text-ink">
{{ systemManagedTab ? activeSystemMediaTitle : (activeFolder || '전체 미디어') }}
</h2>
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
{{ mediaPageTotal }}개 중 {{ mediaPageDisplayStart }}-{{ mediaPageEndIndex }}개 표시
</p>
</div>
<div class="admin-media__selection-actions flex flex-wrap items-center justify-end gap-2">
<label v-if="filteredMediaItems.length" class="admin-media__page-size flex items-center gap-2 text-xs font-semibold text-muted">
<span>표시</span>
<span class="admin-media__page-size-select-wrap relative inline-flex">
<select
class="admin-media__page-size-select appearance-none rounded border border-line bg-white py-2 pl-2 pr-8 text-xs font-semibold text-ink outline-none transition focus:border-[#8e9cac]"
:value="mediaPageSize"
@change="setMediaPageSize"
>
<option v-for="size in mediaPageSizeOptions" :key="size" :value="size">
{{ size }}개
</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</label>
<button
v-if="filteredMediaItems.length"
class="admin-media__select-all rounded border border-line bg-white px-3 py-2 text-xs font-semibold text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:aria-pressed="isAllFilteredMediaSelected"
:disabled="!filteredSelectableMediaUrls.length"
@click="toggleFilteredMediaSelection"
>
{{ isAllFilteredMediaSelected ? '현재 페이지 해제' : '현재 페이지 선택' }}
</button>
<div v-if="selectedMediaUrls.length" class="admin-media__selection flex flex-wrap items-center gap-2 rounded border border-line bg-white px-3 py-2 text-xs">
<strong class="admin-media__selection-count text-ink">{{ selectedMediaUrls.length }}개 선택됨</strong>
<button
class="admin-media__selection-delete font-semibold text-red-600 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="deletingSelected"
@click="deleteSelectedMedia"
>
{{ deletingSelected ? '삭제 중' : '선택 삭제' }}
</button>
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection">
선택 해제
</button>
</div>
</div>
</div>
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
<div
v-for="(item, index) in paginatedMediaItems"
:key="item.url"
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]"
:draggable="activeTab === 'library'"
@dragstart="startMediaDrag($event, item)"
>
<button
type="button"
class="admin-media__select-toggle absolute left-1.5 top-1.5 z-10 grid size-7 place-items-center rounded-md border-2 shadow-md outline-none transition focus-visible:ring-2 focus-visible:ring-[#15171a] focus-visible:ring-offset-1"
:class="isMediaSelected(item)
? 'border-white bg-[#15171a] text-white'
: 'border-[#394047] bg-white/95 text-[#15171a] hover:border-[#15171a] hover:bg-white'"
:aria-label="`${item.name} 선택`"
:aria-pressed="isMediaSelected(item)"
@click.stop="toggleMediaSelection(item, index, $event)"
>
<svg
v-if="isMediaSelected(item)"
class="size-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</button>
<button
class="admin-media__thumb relative flex w-full flex-col text-left outline-none"
type="button"
@click="openMediaDetail(item)"
>
<img
v-if="getMediaItemKind(item) === 'image'"
class="admin-media__image aspect-square w-full bg-surface object-cover"
:src="getMediaGridPreviewUrl(item)"
:alt="item.title"
loading="lazy"
decoding="async"
@error="onMediaGridImageError($event, item)"
>
<AdminMediaVideoThumbnail
v-else-if="getMediaItemKind(item) === 'video'"
:src="item.url"
:alt="item.title"
/>
<span
v-else
class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted"
>
{{ getMediaItemKind(item) }}
</span>
<span
v-if="getMediaUsageBadgeCount(item)"
class="admin-media__usage-count-badge pointer-events-none absolute right-1.5 top-1.5 rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
{{ getMediaUsageBadgeCount(item) }}
</span>
<span
v-if="getMediaBadgeLabel(item)"
class="admin-media__usage-badge pointer-events-none absolute right-1.5 rounded px-1.5 py-0.5 text-[10px] font-semibold"
:class="[
getMediaBadgeClass(item),
getMediaUsageBadgeCount(item) ? 'top-8' : 'top-1.5'
]"
>
{{ getMediaBadgeLabel(item) }}
</span>
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
{{ item.name }}
</span>
</button>
</div>
</div>
<nav
v-if="mediaPageCount > 1"
class="admin-media__pagination mt-6 flex flex-wrap items-center justify-center gap-2"
aria-label="미디어 페이지"
>
<button
class="admin-media__pagination-arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="mediaPage <= 1"
aria-label="이전 페이지"
@click="setMediaPage(mediaPage - 1)"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<template v-for="item in mediaPaginationItems" :key="item">
<span v-if="typeof item === 'string'" class="admin-media__pagination-ellipsis px-2 text-sm font-semibold text-muted">
...
</span>
<button
v-else
class="admin-media__pagination-page grid size-9 place-items-center rounded-full text-sm font-semibold transition"
:class="item === mediaPage ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-white hover:text-ink'"
type="button"
:aria-current="item === mediaPage ? 'page' : undefined"
@click="setMediaPage(item)"
>
{{ item }}
</button>
</template>
<button
class="admin-media__pagination-arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="mediaPage >= mediaPageCount"
aria-label="다음 페이지"
@click="setMediaPage(mediaPage + 1)"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</nav>
<p v-else class="admin-media__empty mt-5 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
표시할 미디어가 없습니다.
</p>
</div>
</div>
<div
v-if="isCreateFolderModalOpen"
class="admin-media__folder-create-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="media-folder-modal-title"
@click.self="closeCreateFolderModal"
>
<section class="admin-media__folder-create-panel w-full max-w-sm rounded border border-line bg-white p-5 shadow-xl">
<h3 id="media-folder-modal-title" class="text-lg font-semibold text-ink">
새 폴더
</h3>
<p class="mt-1 text-xs text-muted">
{{ activeFolder ? `${activeFolder} 아래에 하위 폴더를 만듭니다.` : '최상위 폴더를 만듭니다.' }}
</p>
<label class="mt-4 grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">폴더 이름</span>
<input
v-model="createFolderModalName"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="폴더 이름"
@keydown.enter.prevent="submitCreateFolderModal"
>
</label>
<div class="mt-5 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closeCreateFolderModal">
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="!createFolderModalName.trim()"
@click="submitCreateFolderModal"
>
만들기
</button>
</div>
</section>
</div>
<div
v-if="selectedMedia"
class="admin-media__modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
@click.self="closeMediaDetail"
>
<section class="admin-media__modal-panel grid max-h-[86vh] w-full max-w-5xl overflow-hidden bg-white text-ink shadow-xl lg:grid-cols-[minmax(0,1fr)_22rem]">
<div class="admin-media__preview grid min-h-[20rem] place-items-center bg-[#f5f5f2] p-5">
<img
v-if="getMediaItemKind(selectedMedia) === 'image'"
class="admin-media__preview-image max-h-[72vh] max-w-full object-contain"
:src="getMediaPreviewUrl(selectedMedia)"
:alt="selectedMedia.title"
>
<video
v-else-if="getMediaItemKind(selectedMedia) === 'video'"
class="admin-media__preview-image max-h-[72vh] max-w-full bg-black"
:src="selectedMedia.url"
controls
preload="metadata"
/>
<audio
v-else-if="getMediaItemKind(selectedMedia) === 'audio'"
class="w-full max-w-xl"
:src="selectedMedia.url"
controls
preload="metadata"
/>
<span
v-else
class="rounded border border-line bg-white px-5 py-4 text-sm font-semibold text-muted"
>
{{ selectedMedia.name }}
</span>
</div>
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
<div class="admin-media__detail-header flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
Attachment
</p>
<h2 class="admin-media__detail-title mt-1 break-all text-xl font-semibold">
{{ selectedMedia.name }}
</h2>
</div>
<div class="admin-media__detail-header-actions flex shrink-0 flex-wrap items-center gap-2">
<button
class="admin-media__download rounded border border-line px-3 py-1.5 text-sm font-semibold text-ink hover:bg-surface"
type="button"
@click="downloadSelectedMedia"
>
다운로드
</button>
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
닫기
</button>
</div>
</div>
<dl class="admin-media__info grid gap-3 text-sm">
<div class="admin-media__info-row">
<dt class="admin-media__info-label text-xs font-semibold text-muted">경로</dt>
<dd class="admin-media__info-value mt-1 break-all">{{ selectedMedia.url }}</dd>
</div>
<div class="admin-media__info-row">
<dt class="admin-media__info-label text-xs font-semibold text-muted">용량</dt>
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
</div>
<div v-if="selectedMedia.thumbnailStatus" class="admin-media__info-row">
<dt class="admin-media__info-label text-xs font-semibold text-muted">
{{ selectedMedia.thumbnailStatus.variant === 'pageCover' || selectedMedia.thumbnailStatus.role === 'pageCover' ? '페이지 커버 썸네일' : '카드 썸네일' }}
</dt>
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
<span v-if="selectedMedia.thumbnailStatus.role === 'card'">
자동 생성된 목록 카드용 썸네일입니다.
</span>
<span v-else-if="selectedMedia.thumbnailStatus.role === 'pageCover'">
자동 생성된 Pages 진열장용 커버 썸네일입니다.
</span>
<template v-else>
<span v-if="selectedMedia.thumbnailStatus.hasThumbnail">
생성됨: {{ selectedMedia.thumbnailStatus.thumbnailUrl }}
</span>
<span v-else-if="selectedMedia.thumbnailStatus.isFallbackActive && selectedMedia.thumbnailStatus.variant === 'pageCover'" class="font-semibold text-amber-700">
페이지 커버 썸네일 없음. 현재 Pages 진열장에서 원본 이미지를 불러옵니다.
</span>
<span v-else-if="selectedMedia.thumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
썸네일 없음. 현재 목록에서 원본 이미지를 불러옵니다.
</span>
<span v-else-if="selectedMedia.thumbnailStatus.variant === 'pageCover'">
페이지 커버 썸네일 없음. 홈 Pages 진열장에서 사용되면 원본으로 대체됩니다.
</span>
<span v-else>
썸네일 없음. 대표 이미지로 사용되면 원본으로 대체됩니다.
</span>
</template>
<span v-if="selectedMedia.thumbnailStatus.originalUrl" class="text-muted">
원본: {{ selectedMedia.thumbnailStatus.originalUrl }}
</span>
<button
v-if="canRegeneratePostThumbnail(selectedMedia)"
class="admin-media__thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="regeneratingThumbnailUrl === selectedMedia.url"
@click="regeneratePostThumbnail(selectedMedia)"
>
{{ regeneratingThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.thumbnailStatus.hasThumbnail ? '카드 썸네일 다시 생성' : '카드 썸네일 생성') }}
</button>
<button
v-if="canRegeneratePageCoverThumbnail(selectedMedia)"
class="admin-media__page-cover-thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="regeneratingThumbnailUrl === selectedMedia.url"
@click="regeneratePageCoverThumbnail(selectedMedia)"
>
{{ regeneratingThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.thumbnailStatus.hasThumbnail ? '페이지 커버 썸네일 다시 생성' : '페이지 커버 썸네일 생성') }}
</button>
</dd>
</div>
<div v-if="selectedMedia.bodyThumbnailStatus" class="admin-media__info-row">
<dt class="admin-media__info-label text-xs font-semibold text-muted">본문 이미지</dt>
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
<span v-if="selectedMedia.bodyThumbnailStatus.role === 'body'">
자동 생성된 본문 표시용 WebP 이미지입니다. ({{ getPostBodyThumbnailResolutionLabel(selectedMedia) }})
</span>
<template v-else>
<span v-if="selectedMedia.bodyThumbnailStatus.hasThumbnail">
생성됨: {{ selectedMedia.bodyThumbnailStatus.thumbnailUrl }}
</span>
<span v-else-if="selectedMedia.bodyThumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
본문 이미지 없음. 현재 본문에서 원본 이미지를 불러옵니다.
</span>
<span v-else>
본문 이미지 없음. 게시물 본문에서 사용되면 원본으로 대체됩니다.
</span>
</template>
<span v-if="selectedMedia.bodyThumbnailStatus.originalUrl" class="text-muted">
원본: {{ selectedMedia.bodyThumbnailStatus.originalUrl }}
</span>
<button
v-if="canRegeneratePostBodyThumbnail(selectedMedia)"
class="admin-media__body-thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="regeneratingBodyThumbnailUrl === selectedMedia.url"
@click="regeneratePostBodyThumbnail(selectedMedia)"
>
{{ regeneratingBodyThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.bodyThumbnailStatus.hasThumbnail ? '본문 이미지 다시 생성' : '본문 이미지 생성') }}
</button>
</dd>
</div>
</dl>
<div class="admin-media__category grid gap-2">
<label class="admin-media__category-label text-xs font-semibold text-muted" for="media-category">
폴더
</label>
<div class="admin-media__category-row flex gap-2">
<input
id="media-category"
v-model="editingCategory"
class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-surface disabled:opacity-70"
type="text"
list="media-folder-options"
placeholder="미분류"
:disabled="!canEditMediaCategory(selectedMedia)"
@keydown.enter.prevent="saveMediaCategory"
>
<button
class="admin-media__category-save rounded border border-line px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="!canEditMediaCategory(selectedMedia)"
@click="saveMediaCategory"
>
저장
</button>
</div>
<p v-if="selectedMedia.avatarOwner" class="admin-media__category-hint text-xs text-muted">
프로필 썸네일의 논리 폴더는 「{{ MEDIA_THUMBNAIL_ROOT }}」로 고정됩니다.
</p>
<p v-else-if="isPostCardThumbnailDiskItem(selectedMedia)" class="admin-media__category-hint text-xs text-muted">
카드 썸네일은 원본 이미지에 연결된 자동 생성 파일이라 폴더를 직접 바꾸지 않습니다.
</p>
<datalist id="media-folder-options">
<option v-for="folder in normalizedFolders" :key="folder" :value="folder" />
</datalist>
</div>
<div
v-if="selectedMedia.avatarOwner"
class="admin-media__avatar-owner rounded border border-line bg-surface p-3 text-xs"
>
<strong class="admin-media__avatar-owner-title text-ink">연결된 회원</strong>
<dl class="admin-media__avatar-owner-fields mt-2 grid gap-2 text-muted">
<div class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">닉네임</dt>
<dd class="mt-0.5 font-semibold text-ink">{{ selectedMedia.avatarOwner.username }}</dd>
</div>
<div class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">이메일</dt>
<dd class="mt-0.5 break-all text-ink">{{ selectedMedia.avatarOwner.email }}</dd>
</div>
<div class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">등급</dt>
<dd class="mt-0.5 text-ink">{{ getMediaBadgeLabel(selectedMedia) }}</dd>
</div>
<div class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 접속</dt>
<dd class="mt-0.5 text-ink">{{ formatDateTime(selectedMedia.avatarOwner.lastSeenAt) }}</dd>
</div>
<div v-if="selectedMedia.avatarOwner.lastSeenIp" class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 IP</dt>
<dd class="mt-0.5 break-all font-mono text-[11px] text-ink">{{ selectedMedia.avatarOwner.lastSeenIp }}</dd>
</div>
</dl>
</div>
<div v-if="!selectedMedia.avatarOwner" class="admin-media__usage rounded bg-surface p-3 text-xs">
<strong class="admin-media__usage-title text-ink">
사용 현황 {{ selectedMedia.usage.length }}곳
</strong>
<ul v-if="selectedMedia.usage.length" class="admin-media__usage-list mt-2 grid gap-1.5 text-muted">
<li v-for="usage in selectedMedia.usage" :key="`${selectedMedia.url}-${usage.type}-${usage.id}-${usage.location}`" class="admin-media__usage-item">
<NuxtLink
v-if="usage.adminUrl"
class="admin-media__usage-link font-semibold text-ink hover:opacity-70"
:to="usage.adminUrl"
>
{{ usage.title }}
</NuxtLink>
<span v-else class="admin-media__usage-name font-semibold text-ink">{{ usage.title }}</span>
<span class="admin-media__usage-meta"> · {{ usage.typeLabel }} · {{ usage.label }}</span>
</li>
</ul>
<p v-else class="admin-media__usage-empty mt-2 text-muted">
사용 중인 곳이 없습니다.
</p>
</div>
<div class="admin-media__rename grid gap-2">
<label class="admin-media__rename-label text-xs font-semibold text-muted" for="media-name">
파일명
</label>
<input
id="media-name"
v-model="editingName"
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50"
type="text"
:disabled="!canRenameMediaItem(selectedMedia)"
:placeholder="selectedMedia.title"
@keydown.enter.prevent="renameMedia"
>
<p v-if="isMediaItemLocked(selectedMedia)" class="admin-media__locked text-xs text-muted">
게시물·페이지에서 사용 중이거나, 회원 프로필에 연결된 썸네일은 파일명 변경과 삭제가 잠깁니다.
</p>
<p v-else-if="isPostCardThumbnailDiskItem(selectedMedia) || isPageCoverThumbnailDiskItem(selectedMedia) || isPostBodyThumbnailDiskItem(selectedMedia)" class="admin-media__locked text-xs text-muted">
자동 생성 이미지는 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다. 사용처가 없는 파생 파일만 정리용으로 삭제할 수 있습니다.
</p>
</div>
<div class="admin-media__actions flex flex-wrap gap-2">
<button
class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="!canRenameMediaItem(selectedMedia) || !editingName"
@click="renameMedia"
>
파일명 저장
</button>
<button
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
type="button"
:disabled="deletingUrl === selectedMedia.url || isMediaItemLocked(selectedMedia)"
@click="deleteMedia(selectedMedia)"
>
{{ deletingUrl === selectedMedia.url ? '삭제 중' : '삭제' }}
</button>
</div>
</aside>
</section>
</div>
<div
v-if="toast"
class="admin-media__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
:class="{
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
'border-line bg-white text-ink': toast.type === 'info'
}"
role="status"
>
{{ toast.message }}
</div>
</section>
</template>