관리자 미디어 카드 썸네일 탭 분리

This commit is contained in:
2026-06-08 14:57:38 +09:00
parent 664d2f98aa
commit eb4018f92c
11 changed files with 332 additions and 41 deletions

View File

@@ -55,6 +55,20 @@ const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
*/
const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avatars/'))
/**
* 게시물 카드 썸네일 디스크 경로 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 게시물 카드 썸네일이면 true
*/
const isPostCardThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/posts/') && item?.url?.includes('/thumbs/') && item?.url?.endsWith('-card.webp'))
/**
* 미디어 항목이 시스템 파생 파일인지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 시스템 관리 항목이면 true
*/
const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item)
/**
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
* @param {Object} item - 미디어 항목
@@ -62,6 +76,66 @@ const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avat
*/
const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner)
/**
* 미디어 파일명을 변경할 수 있는지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 변경 가능 여부
*/
const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item)
/**
* 미디어 논리 폴더를 변경할 수 있는지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 변경 가능 여부
*/
const canEditMediaCategory = (item) => !isSystemManagedMediaItem(item)
/**
* 미디어 카드 배지 라벨을 반환한다.
* @param {Object} item - 미디어 항목
* @returns {string} 배지 라벨
*/
const getMediaBadgeLabel = (item) => {
if (item?.avatarOwner) {
return '회원'
}
if (item?.thumbnailStatus?.role === 'card') {
return '카드'
}
if (item?.thumbnailStatus?.isFallbackActive) {
return '원본'
}
if (item?.usage?.length) {
return String(item.usage.length)
}
return ''
}
/**
* 미디어 카드 배지 클래스를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {string} Tailwind 클래스
*/
const getMediaBadgeClass = (item) => {
if (item?.thumbnailStatus?.isFallbackActive) {
return 'bg-amber-600 text-white'
}
if (item?.thumbnailStatus?.role === 'card') {
return 'bg-sky-700 text-white'
}
if (item?.avatarOwner) {
return 'bg-emerald-800 text-white'
}
return 'bg-[#15171a] text-white'
}
/**
* 미디어 항목 종류를 반환한다.
* @param {Object} item - 미디어 항목
@@ -69,17 +143,45 @@ const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item
*/
const getMediaItemKind = (item) => item?.kind || 'image'
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item)))
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item) && !isPostCardThumbnailDiskItem(item)))
const postThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostCardThumbnailDiskItem(item)))
const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isThumbnailDiskItem(item)))
const scopeItems = computed(() => (activeTab.value === 'thumbnails' ? thumbnailMediaItems.value : libraryMediaItems.value))
const scopeItems = computed(() => {
if (activeTab.value === 'thumbnails') {
return thumbnailMediaItems.value
}
if (activeTab.value === 'postThumbnails') {
return postThumbnailMediaItems.value
}
return libraryMediaItems.value
})
const systemManagedTab = computed(() => activeTab.value === 'thumbnails' || activeTab.value === 'postThumbnails')
const activeSystemMediaCount = computed(() => activeTab.value === 'postThumbnails'
? postThumbnailMediaItems.value.length
: thumbnailMediaItems.value.length)
const activeSystemMediaTitle = computed(() => activeTab.value === 'postThumbnails' ? '카드 썸네일' : '프로필 이미지')
const activeSystemMediaHint = computed(() => {
if (activeTab.value === 'postThumbnails') {
return '게시물 목록 카드에서 쓰는 자동 생성 이미지입니다. 원본 대표 이미지가 사용 중이고 이 썸네일 파일이 있으면 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 썸네일은 사용 중으로 표시되며, 썸네일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.'
}
return '회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제해도 디스크 파일은 삭제되지 않으며 이 목록에 남습니다. 목록이 바로 안 바뀌면 페이지를 새로고침하세요. 관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다.'
})
const mediaKindFilterOptions = computed(() => {
const baseItems = scopeItems.value.filter((item) => {
const folder = activeFolder.value
return activeTab.value === 'thumbnails'
return systemManagedTab.value
? true
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
})
@@ -100,7 +202,7 @@ const mediaKindFilterOptions = computed(() => {
const unusedMediaCount = computed(() => scopeItems.value.filter((item) => {
const folder = activeFolder.value
const matchesFolder = activeTab.value === 'thumbnails'
const matchesFolder = systemManagedTab.value
? true
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
@@ -109,7 +211,7 @@ const unusedMediaCount = computed(() => scopeItems.value.filter((item) => {
/**
* 상단 탭 전환 시 목록 상태를 초기화한다.
* @param {'library' | 'thumbnails'} tab - 선택 탭
* @param {'library' | 'postThumbnails' | 'thumbnails'} tab - 선택 탭
* @returns {void}
*/
const setActiveTab = (tab) => {
@@ -227,7 +329,7 @@ const filteredMediaItems = computed(() => {
const base = scopeItems.value
return base.filter((item) => {
const matchesFolder = activeTab.value === 'thumbnails'
const matchesFolder = systemManagedTab.value
? true
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
const usageTitles = item.usage?.map((usage) => usage.title) || []
@@ -636,7 +738,7 @@ const dropMediaOnFolder = async (folder) => {
* @returns {Promise<void>} 저장 결과
*/
const saveMediaCategory = async () => {
if (selectedMedia.value?.avatarOwner) {
if (!canEditMediaCategory(selectedMedia.value)) {
return
}
@@ -664,8 +766,8 @@ const saveMediaCategory = async () => {
const renameMedia = async () => {
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
if (editingItem && isMediaItemLocked(editingItem)) {
showToast('error', '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 파일명을 바꿀 수 없습니다.')
if (editingItem && !canRenameMediaItem(editingItem)) {
showToast('error', '사용 중인 미디어 또는 카드 썸네일은 파일명을 바꿀 수 없습니다.')
return
}
@@ -795,6 +897,14 @@ watch(filteredMediaUrls, (urls) => {
>
미디어 라이브러리
</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 === 'thumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
@@ -943,11 +1053,11 @@ watch(filteredMediaUrls, (urls) => {
type="button"
@click="selectFolder('')"
>
<span>전체 이미지</span>
<span>{{ thumbnailMediaItems.length }}</span>
<span>{{ activeSystemMediaTitle }}</span>
<span>{{ activeSystemMediaCount }}</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>관리자는 필요 삭제·다운로드로 정리할 있습니다.
{{ activeSystemMediaHint }}
</p>
</aside>
@@ -955,7 +1065,7 @@ watch(filteredMediaUrls, (urls) => {
<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 || '전체 미디어') }}
{{ systemManagedTab ? activeSystemMediaTitle : (activeFolder || '전체 미디어') }}
</h2>
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
{{ filteredMediaItems.length }} 표시
@@ -1044,16 +1154,11 @@ watch(filteredMediaUrls, (urls) => {
{{ 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"
v-if="getMediaBadgeLabel(item)"
class="admin-media__usage-badge pointer-events-none absolute right-1.5 top-1.5 rounded px-1.5 py-0.5 text-[10px] font-semibold"
:class="getMediaBadgeClass(item)"
>
회원
</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 }}
{{ getMediaBadgeLabel(item) }}
</span>
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
{{ item.name }}
@@ -1179,6 +1284,28 @@ watch(filteredMediaUrls, (urls) => {
<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">카드 썸네일</dt>
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
<span v-if="selectedMedia.thumbnailStatus.role === 'card'">
자동 생성된 목록 카드용 썸네일입니다.
</span>
<template v-else>
<span v-if="selectedMedia.thumbnailStatus.hasThumbnail">
생성됨: {{ selectedMedia.thumbnailStatus.thumbnailUrl }}
</span>
<span v-else-if="selectedMedia.thumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
썸네일 없음. 현재 목록에서 원본 이미지를 불러옵니다.
</span>
<span v-else>
썸네일 없음. 대표 이미지로 사용되면 원본으로 대체됩니다.
</span>
</template>
<span v-if="selectedMedia.thumbnailStatus.originalUrl" class="text-muted">
원본: {{ selectedMedia.thumbnailStatus.originalUrl }}
</span>
</dd>
</div>
</dl>
<div class="admin-media__category grid gap-2">
@@ -1193,13 +1320,13 @@ watch(filteredMediaUrls, (urls) => {
type="text"
list="media-folder-options"
placeholder="미분류"
:disabled="Boolean(selectedMedia.avatarOwner)"
: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="Boolean(selectedMedia.avatarOwner)"
:disabled="!canEditMediaCategory(selectedMedia)"
@click="saveMediaCategory"
>
저장
@@ -1208,6 +1335,9 @@ watch(filteredMediaUrls, (urls) => {
<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>
@@ -1269,20 +1399,23 @@ watch(filteredMediaUrls, (urls) => {
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)"
: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)" 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"
:disabled="!canRenameMediaItem(selectedMedia) || !editingName"
@click="renameMedia"
>
파일명 저장