1105 lines
39 KiB
Vue
1105 lines
39 KiB
Vue
<script setup>
|
|
definePageMeta({
|
|
layout: 'admin'
|
|
})
|
|
|
|
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
|
|
const MEDIA_THUMBNAIL_ROOT = '썸네일'
|
|
|
|
/**
|
|
* 논리 폴더 경로가 썸네일 전용 루트인지 확인한다.
|
|
* @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 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 selectedMediaUrl = ref('')
|
|
const selectedMediaUrls = ref([])
|
|
const lastSelectedIndex = ref(-1)
|
|
const draggingUrls = ref([])
|
|
|
|
const { toast, showToast } = useAdminToast()
|
|
|
|
const { openMenuId, closeMenu } = useAdminRowMenu()
|
|
|
|
/**
|
|
* 폴더 행 메뉴 id
|
|
* @param {string} folder - 폴더 경로
|
|
* @returns {string}
|
|
*/
|
|
const getFolderMenuId = (folder) => `folder:${folder}`
|
|
|
|
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
|
|
default: () => []
|
|
})
|
|
|
|
/**
|
|
* 썸네일 디스크 경로 여부
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 회원 아바타 경로이면 true
|
|
*/
|
|
const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avatars/'))
|
|
|
|
/**
|
|
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 잠금이면 true
|
|
*/
|
|
const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner)
|
|
|
|
/**
|
|
* 미디어 항목 종류를 반환한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
|
|
*/
|
|
const getMediaItemKind = (item) => item?.kind || 'image'
|
|
|
|
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item)))
|
|
|
|
const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isThumbnailDiskItem(item)))
|
|
|
|
const scopeItems = computed(() => (activeTab.value === 'thumbnails' ? thumbnailMediaItems.value : libraryMediaItems.value))
|
|
|
|
const mediaKindFilterOptions = computed(() => {
|
|
const baseItems = scopeItems.value.filter((item) => {
|
|
const folder = activeFolder.value
|
|
|
|
return activeTab.value === 'thumbnails'
|
|
? true
|
|
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
|
|
})
|
|
const countByKind = baseItems.reduce((counts, item) => {
|
|
const kind = getMediaItemKind(item)
|
|
counts[kind] = (counts[kind] || 0) + 1
|
|
return counts
|
|
}, {})
|
|
|
|
return [
|
|
{ id: 'all', label: '전체', count: baseItems.length },
|
|
{ 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 unusedMediaCount = computed(() => scopeItems.value.filter((item) => {
|
|
const folder = activeFolder.value
|
|
const matchesFolder = activeTab.value === 'thumbnails'
|
|
? true
|
|
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
|
|
|
|
return matchesFolder && !isMediaItemLocked(item)
|
|
}).length)
|
|
|
|
/**
|
|
* 상단 탭 전환 시 목록 상태를 초기화한다.
|
|
* @param {'library' | '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()
|
|
}
|
|
|
|
/**
|
|
* 미사용 미디어만 보기 필터를 토글한다.
|
|
* @returns {void}
|
|
*/
|
|
const toggleUnusedMediaFilter = () => {
|
|
showUnusedOnly.value = !showUnusedOnly.value
|
|
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(['미분류'])
|
|
|
|
mediaFolders.value.forEach((folder) => {
|
|
String(folder || '').split('/').filter(Boolean).reduce((parentPath, segment) => {
|
|
const nextPath = parentPath ? `${parentPath}/${segment}` : segment
|
|
folderSet.add(nextPath)
|
|
return nextPath
|
|
}, '')
|
|
})
|
|
|
|
libraryMediaItems.value.forEach((item) => {
|
|
String(item.category || '미분류').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] = libraryMediaItems.value.filter((item) => item.category === folder || item.category?.startsWith(`${folder}/`)).length
|
|
return counts
|
|
}, {}))
|
|
|
|
const filteredMediaItems = computed(() => {
|
|
const query = searchText.value.trim().toLowerCase()
|
|
const folder = activeFolder.value
|
|
const mediaKind = activeMediaKind.value
|
|
const unusedOnly = showUnusedOnly.value
|
|
const base = scopeItems.value
|
|
|
|
return base.filter((item) => {
|
|
const matchesFolder = activeTab.value === 'thumbnails'
|
|
? true
|
|
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
|
|
const usageTitles = item.usage?.map((usage) => usage.title) || []
|
|
const matchesQuery = !query || [
|
|
item.name,
|
|
...usageTitles
|
|
].some((value) => String(value || '').toLowerCase().includes(query))
|
|
const matchesKind = mediaKind === 'all' || getMediaItemKind(item) === mediaKind
|
|
const matchesUsage = !unusedOnly || !isMediaItemLocked(item)
|
|
|
|
return matchesFolder && matchesQuery && matchesKind && matchesUsage
|
|
})
|
|
})
|
|
|
|
/**
|
|
* 파일 크기 표시 문자열 생성
|
|
* @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 = filteredMediaItems.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
|
|
}
|
|
|
|
/**
|
|
* 미디어 상세 모달 열기
|
|
* @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 (selectedMedia.value?.avatarOwner) {
|
|
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 && isMediaItemLocked(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 = ''
|
|
}
|
|
}
|
|
</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 === 'thumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
|
|
type="button"
|
|
@click="setActiveTab('thumbnails')"
|
|
>
|
|
프로필 이미지
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<input
|
|
v-model="searchText"
|
|
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
|
|
type="search"
|
|
:placeholder="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'"
|
|
>
|
|
</div>
|
|
<div
|
|
v-if="activeTab === 'library'"
|
|
class="admin-media__filters flex flex-wrap gap-1.5"
|
|
>
|
|
<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>
|
|
<button
|
|
class="admin-media__unused-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
|
:class="showUnusedOnly ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
|
|
type="button"
|
|
:aria-pressed="showUnusedOnly"
|
|
@click="toggleUnusedMediaFilter"
|
|
>
|
|
미사용
|
|
<span class="ml-1 opacity-70">{{ unusedMediaCount }}</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>{{ libraryMediaItems.length }}</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>전체 이미지</span>
|
|
<span>{{ thumbnailMediaItems.length }}</span>
|
|
</button>
|
|
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
|
|
회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. <br> 프로필에서 바꾸거나 해제해도 <strong class="font-semibold text-ink">디스크 파일은 삭제되지 않으며</strong> 이 목록에 남습니다. <br>목록이 바로 안 바뀌면 페이지를 새로고침하세요. <br>관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다.
|
|
</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">
|
|
{{ activeTab === 'thumbnails' ? '썸네일' : (activeFolder || '전체 미디어') }}
|
|
</h2>
|
|
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
|
|
{{ filteredMediaItems.length }}개 표시
|
|
</p>
|
|
</div>
|
|
<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-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection">
|
|
선택 해제
|
|
</button>
|
|
</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 filteredMediaItems"
|
|
: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="item.url"
|
|
:alt="item.title"
|
|
>
|
|
<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="item.avatarOwner"
|
|
class="admin-media__usage-badge pointer-events-none absolute right-1.5 top-1.5 rounded bg-emerald-800 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
|
>
|
|
회원
|
|
</span>
|
|
<span
|
|
v-else-if="item.usage.length"
|
|
class="admin-media__usage-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"
|
|
>
|
|
{{ item.usage.length }}
|
|
</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>
|
|
|
|
<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="selectedMedia.url"
|
|
: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>
|
|
</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="Boolean(selectedMedia.avatarOwner)"
|
|
@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="Boolean(selectedMedia.avatarOwner)"
|
|
@click="saveMediaCategory"
|
|
>
|
|
저장
|
|
</button>
|
|
</div>
|
|
<p v-if="selectedMedia.avatarOwner" class="admin-media__category-hint text-xs text-muted">
|
|
프로필 썸네일의 논리 폴더는 「{{ MEDIA_THUMBNAIL_ROOT }}」로 고정됩니다.
|
|
</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">{{ 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="isMediaItemLocked(selectedMedia)"
|
|
:placeholder="selectedMedia.title"
|
|
@keydown.enter.prevent="renameMedia"
|
|
>
|
|
<p v-if="isMediaItemLocked(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="isMediaItemLocked(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>
|