본문 이미지 최적화·라이트박스·번호형 페이지네이션 반영 (v1.10.3)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-01 16:19:45 +09:00
parent 0eca6b288e
commit c686b1c848
20 changed files with 812 additions and 143 deletions

View File

@@ -34,6 +34,7 @@ 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)
@@ -71,12 +72,19 @@ const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avat
*/
const isPostCardThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/posts/') && item?.url?.includes('/thumbs/') && item?.url?.endsWith('-card.webp'))
/**
* 게시물 본문 표시용 이미지 디스크 경로 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 게시물 본문 표시용 이미지이면 true
*/
const isPostBodyThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/posts/') && item?.url?.includes('/thumbs/') && item?.url?.endsWith('-body.webp'))
/**
* 미디어 항목이 시스템 파생 파일인지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 시스템 관리 항목이면 true
*/
const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item)
const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item) || isPostBodyThumbnailDiskItem(item)
/**
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
@@ -90,7 +98,7 @@ const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item
* @param {Object} item - 미디어 항목
* @returns {boolean} 변경 가능 여부
*/
const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item)
const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)
/**
* 미디어 논리 폴더를 변경할 수 있는지 확인한다.
@@ -114,10 +122,22 @@ const getMediaBadgeLabel = (item) => {
return ''
}
if (activeTab.value === 'postBodyThumbnails' && item?.bodyThumbnailStatus?.role === 'body') {
return ''
}
if (item?.thumbnailStatus?.role === 'card') {
return '카드'
}
if (item?.bodyThumbnailStatus?.role === 'body') {
return '본문'
}
if (item?.bodyThumbnailStatus?.isFallbackActive) {
return '원본'
}
if (item?.thumbnailStatus?.isFallbackActive) {
return '원본'
}
@@ -131,7 +151,7 @@ const getMediaBadgeLabel = (item) => {
* @returns {string} Tailwind 클래스
*/
const getMediaBadgeClass = (item) => {
if (item?.thumbnailStatus?.isFallbackActive) {
if (item?.thumbnailStatus?.isFallbackActive || item?.bodyThumbnailStatus?.isFallbackActive) {
return 'bg-amber-600 text-white'
}
@@ -139,6 +159,10 @@ const getMediaBadgeClass = (item) => {
return 'bg-sky-700 text-white'
}
if (item?.bodyThumbnailStatus?.role === 'body') {
return 'bg-violet-700 text-white'
}
if (item?.avatarOwner) {
return 'bg-emerald-800 text-white'
}
@@ -168,6 +192,16 @@ const canRegeneratePostThumbnail = (item) => (
&& (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 - 미디어 항목
@@ -175,10 +209,12 @@ const canRegeneratePostThumbnail = (item) => (
*/
const getMediaItemKind = (item) => item?.kind || 'image'
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item) && !isPostCardThumbnailDiskItem(item)))
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item) && !isPostCardThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)))
const postThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostCardThumbnailDiskItem(item)))
const postBodyThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostBodyThumbnailDiskItem(item)))
const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isThumbnailDiskItem(item)))
const scopeItems = computed(() => {
@@ -190,22 +226,48 @@ const scopeItems = computed(() => {
return postThumbnailMediaItems.value
}
if (activeTab.value === 'postBodyThumbnails') {
return postBodyThumbnailMediaItems.value
}
return libraryMediaItems.value
})
const systemManagedTab = computed(() => activeTab.value === 'thumbnails' || activeTab.value === 'postThumbnails')
const systemManagedTab = computed(() => ['thumbnails', 'postThumbnails', 'postBodyThumbnails'].includes(activeTab.value))
const activeSystemMediaCount = computed(() => activeTab.value === 'postThumbnails'
? postThumbnailMediaItems.value.length
: thumbnailMediaItems.value.length)
const activeSystemMediaCount = computed(() => {
if (activeTab.value === 'postThumbnails') {
return postThumbnailMediaItems.value.length
}
const activeSystemMediaTitle = computed(() => activeTab.value === 'postThumbnails' ? '카드 썸네일' : '프로필 이미지')
if (activeTab.value === 'postBodyThumbnails') {
return postBodyThumbnailMediaItems.value.length
}
return thumbnailMediaItems.value.length
})
const activeSystemMediaTitle = computed(() => {
if (activeTab.value === 'postThumbnails') {
return '카드 썸네일'
}
if (activeTab.value === 'postBodyThumbnails') {
return '본문 이미지'
}
return '프로필 이미지'
})
const activeSystemMediaHint = computed(() => {
if (activeTab.value === 'postThumbnails') {
return '게시물 목록 카드에서 쓰는 자동 생성 이미지입니다. 원본 대표 이미지가 사용 중이고 이 썸네일 파일이 있으면 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 썸네일은 사용 중으로 표시되며, 썸네일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.'
}
if (activeTab.value === 'postBodyThumbnails') {
return '게시물 본문을 빠르게 보여주기 위해 원본 이미지에서 자동 생성한 WebP 이미지입니다. 본문에는 이 파일을 우선 표시하고, 크게 보기에서는 원본 이미지를 사용합니다. 원본과 연결되어 있어 이름 변경과 폴더 이동은 제한됩니다.'
}
return '회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제해도 디스크 파일은 삭제되지 않으며 이 목록에 남습니다. 목록이 바로 안 바뀌면 페이지를 새로고침하세요. 관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다.'
})
@@ -250,9 +312,13 @@ const mediaUsageFilterOptions = computed(() => {
]
})
const visibleMediaUsageFilterOptions = computed(() => activeTab.value === 'library'
? mediaUsageFilterOptions.value.filter((option) => option.id !== 'all')
: mediaUsageFilterOptions.value)
/**
* 상단 탭 전환 시 목록 상태를 초기화한다.
* @param {'library' | 'postThumbnails' | 'thumbnails'} tab - 선택 탭
* @param {'library' | 'postThumbnails' | 'postBodyThumbnails' | 'thumbnails'} tab - 선택 탭
* @returns {void}
*/
const setActiveTab = (tab) => {
@@ -286,6 +352,11 @@ const setMediaKindFilter = (kind) => {
*/
const setMediaUsageFilter = (unusedOnly) => {
if (showUnusedOnly.value === unusedOnly) {
if (unusedOnly) {
showUnusedOnly.value = false
clearMediaSelection()
}
return
}
@@ -899,6 +970,36 @@ const regeneratePostThumbnail = async (item) => {
}
}
/**
* 선택한 원본 이미지의 본문 표시용 이미지를 다시 생성한다.
* @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>}
@@ -984,6 +1085,14 @@ watch(filteredMediaUrls, (urls) => {
>
카드 썸네일
</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'"
@@ -1041,7 +1150,7 @@ watch(filteredMediaUrls, (urls) => {
</button>
</template>
<button
v-for="option in mediaUsageFilterOptions"
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'"
@@ -1405,6 +1514,37 @@ watch(filteredMediaUrls, (urls) => {
</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 이미지입니다.
</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">