관리자 미디어 라이브러리·썸네일 탭 분리 및 논리 폴더 정책(v0.0.90)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,17 @@ 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 isCreateFolderModalOpen = ref(false)
|
||||
@@ -22,6 +33,63 @@ 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)
|
||||
|
||||
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))
|
||||
|
||||
/**
|
||||
* 상단 탭 전환 시 목록 상태를 초기화한다.
|
||||
* @param {'library' | 'thumbnails'} tab - 선택 탭
|
||||
* @returns {void}
|
||||
*/
|
||||
const setActiveTab = (tab) => {
|
||||
if (activeTab.value === tab) {
|
||||
return
|
||||
}
|
||||
|
||||
activeTab.value = tab
|
||||
activeFolder.value = ''
|
||||
searchText.value = ''
|
||||
clearMediaSelection()
|
||||
closeMediaDetail()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 '—'
|
||||
}
|
||||
}
|
||||
|
||||
const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', {
|
||||
default: () => ['미분류']
|
||||
})
|
||||
@@ -39,7 +107,7 @@ const normalizedFolders = computed(() => {
|
||||
}, '')
|
||||
})
|
||||
|
||||
mediaItems.value.forEach((item) => {
|
||||
libraryMediaItems.value.forEach((item) => {
|
||||
String(item.category || '미분류').split('/').filter(Boolean).reduce((parentPath, segment) => {
|
||||
const nextPath = parentPath ? `${parentPath}/${segment}` : segment
|
||||
folderSet.add(nextPath)
|
||||
@@ -51,21 +119,29 @@ const normalizedFolders = computed(() => {
|
||||
})
|
||||
|
||||
const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts, folder) => {
|
||||
counts[folder] = mediaItems.value.filter((item) => item.category === folder || item.category?.startsWith(`${folder}/`)).length
|
||||
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 base = scopeItems.value
|
||||
|
||||
return mediaItems.value.filter((item) => {
|
||||
const matchesFolder = !folder || item.category === folder || item.category?.startsWith(`${folder}/`)
|
||||
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 ownerFields = item.avatarOwner
|
||||
? [item.avatarOwner.username, item.avatarOwner.email, item.avatarOwner.lastSeenIp]
|
||||
: []
|
||||
const matchesQuery = !query || [
|
||||
item.name,
|
||||
item.url,
|
||||
item.category,
|
||||
...item.usage.map((usage) => usage.title)
|
||||
...usageTitles,
|
||||
...ownerFields
|
||||
].some((value) => String(value || '').toLowerCase().includes(query))
|
||||
|
||||
return matchesFolder && matchesQuery
|
||||
@@ -209,6 +285,10 @@ const closeCreateFolderModal = () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const submitCreateFolderModal = async () => {
|
||||
if (activeTab.value !== 'library') {
|
||||
return
|
||||
}
|
||||
|
||||
const folderName = createFolderModalName.value.trim()
|
||||
|
||||
if (!folderName) {
|
||||
@@ -240,7 +320,7 @@ const submitCreateFolderModal = async () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const removeMediaFolder = async (folder) => {
|
||||
if (!folder || folder === '미분류') {
|
||||
if (!folder || folder === '미분류' || folder === MEDIA_THUMBNAIL_ROOT || folder.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -281,6 +361,10 @@ const removeMediaFolder = async (folder) => {
|
||||
* @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) {
|
||||
@@ -315,6 +399,10 @@ const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const startMediaDrag = (event, item) => {
|
||||
if (activeTab.value !== 'library') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMediaSelected(item)) {
|
||||
selectedMediaUrls.value = [item.url]
|
||||
}
|
||||
@@ -333,6 +421,11 @@ const startMediaDrag = (event, item) => {
|
||||
* @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)
|
||||
@@ -343,6 +436,10 @@ const dropMediaOnFolder = async (folder) => {
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const saveMediaCategory = async () => {
|
||||
if (selectedMedia.value?.avatarOwner) {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
@@ -369,8 +466,8 @@ const saveMediaCategory = async () => {
|
||||
const renameMedia = async () => {
|
||||
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
|
||||
|
||||
if (editingItem?.usage.length) {
|
||||
errorMessage.value = '사용 중인 미디어는 파일명을 변경할 수 없습니다.'
|
||||
if (editingItem && isMediaItemLocked(editingItem)) {
|
||||
errorMessage.value = '사용 중이거나 회원 썸네일인 미디어는 파일명을 변경할 수 없습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -398,8 +495,8 @@ const renameMedia = async () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteMedia = async (item) => {
|
||||
if (item.usage.length) {
|
||||
errorMessage.value = '사용 중인 미디어는 삭제할 수 없습니다.'
|
||||
if (isMediaItemLocked(item)) {
|
||||
errorMessage.value = '사용 중이거나 회원 썸네일인 미디어는 삭제할 수 없습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -429,21 +526,41 @@ const deleteMedia = async (item) => {
|
||||
|
||||
<template>
|
||||
<section class="admin-media bg-paper p-6">
|
||||
<div class="admin-media__header 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__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' ? '닉네임, 이메일, IP, 파일명 검색' : '파일명, 경로, 폴더, 사용처 검색'"
|
||||
>
|
||||
</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="파일명, 경로, 폴더, 사용처 검색"
|
||||
>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-media__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
@@ -451,7 +568,10 @@ const deleteMedia = async (item) => {
|
||||
</p>
|
||||
|
||||
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<aside class="admin-media__folders rounded border border-line bg-white p-3">
|
||||
<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'"
|
||||
@@ -461,7 +581,7 @@ const deleteMedia = async (item) => {
|
||||
@drop.prevent="dropMediaOnFolder('미분류')"
|
||||
>
|
||||
<span>전체 미디어</span>
|
||||
<span>{{ mediaItems.length }}</span>
|
||||
<span>{{ libraryMediaItems.length }}</span>
|
||||
</button>
|
||||
|
||||
<div class="admin-media__folder-list mt-3 grid gap-1">
|
||||
@@ -483,7 +603,7 @@ const deleteMedia = async (item) => {
|
||||
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="folder !== '미분류'"
|
||||
v-if="folder !== '미분류' && !isThumbnailFolderPath(folder)"
|
||||
class="admin-media__folder-delete mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded text-current opacity-40 transition hover:opacity-100 hover:text-red-300 disabled:opacity-25"
|
||||
type="button"
|
||||
:disabled="deletingFolder === folder"
|
||||
@@ -497,7 +617,7 @@ const deleteMedia = async (item) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-media__folder-actions mt-4 border-t border-line pt-4">
|
||||
<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"
|
||||
@@ -508,11 +628,28 @@ const deleteMedia = async (item) => {
|
||||
</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">
|
||||
회원 프로필에서 저장된 이미지만 표시됩니다. 파일 삭제·이름 변경은 이 화면에서 할 수 없으며, 회원이 프로필에서 바꾸면 갱신됩니다.
|
||||
</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">
|
||||
{{ activeFolder || '전체 미디어' }}
|
||||
{{ activeTab === 'thumbnails' ? '썸네일' : (activeFolder || '전체 미디어') }}
|
||||
</h2>
|
||||
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
|
||||
{{ filteredMediaItems.length }}개 표시
|
||||
@@ -531,7 +668,7 @@ const deleteMedia = async (item) => {
|
||||
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="true"
|
||||
:draggable="activeTab === 'library'"
|
||||
@dragstart="startMediaDrag($event, item)"
|
||||
>
|
||||
<button
|
||||
@@ -566,7 +703,13 @@ const deleteMedia = async (item) => {
|
||||
>
|
||||
<img class="admin-media__image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||
<span
|
||||
v-if="item.usage.length"
|
||||
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 }}
|
||||
@@ -675,22 +818,56 @@ const deleteMedia = async (item) => {
|
||||
<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"
|
||||
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" type="button" @click="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 class="admin-media__usage rounded bg-surface p-3 text-xs">
|
||||
<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>
|
||||
@@ -721,12 +898,12 @@ const deleteMedia = async (item) => {
|
||||
v-model="editingName"
|
||||
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50"
|
||||
type="text"
|
||||
:disabled="selectedMedia.usage.length > 0"
|
||||
:disabled="isMediaItemLocked(selectedMedia)"
|
||||
:placeholder="selectedMedia.title"
|
||||
@keydown.enter.prevent="renameMedia"
|
||||
>
|
||||
<p v-if="selectedMedia.usage.length" class="admin-media__locked text-xs text-muted">
|
||||
사용 중인 미디어는 파일명 변경과 삭제가 잠겨 있습니다.
|
||||
<p v-if="isMediaItemLocked(selectedMedia)" class="admin-media__locked text-xs text-muted">
|
||||
사용 중이거나 회원 썸네일인 미디어는 파일명 변경과 삭제가 잠겨 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -734,7 +911,7 @@ const deleteMedia = async (item) => {
|
||||
<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="selectedMedia.usage.length > 0 || !editingName"
|
||||
:disabled="isMediaItemLocked(selectedMedia) || !editingName"
|
||||
@click="renameMedia"
|
||||
>
|
||||
파일명 저장
|
||||
@@ -742,7 +919,7 @@ const deleteMedia = async (item) => {
|
||||
<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 || selectedMedia.usage.length > 0"
|
||||
:disabled="deletingUrl === selectedMedia.url || isMediaItemLocked(selectedMedia)"
|
||||
@click="deleteMedia(selectedMedia)"
|
||||
>
|
||||
{{ deletingUrl === selectedMedia.url ? '삭제 중' : '삭제' }}
|
||||
|
||||
Reference in New Issue
Block a user