관리자 미디어 카드 썸네일 탭 분리
This commit is contained in:
@@ -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"
|
||||
>
|
||||
파일명 저장
|
||||
|
||||
Reference in New Issue
Block a user