페이지 커버 썸네일 최적화

This commit is contained in:
2026-07-02 10:09:39 +09:00
parent b26ae271d2
commit 3a4e39332a
20 changed files with 463 additions and 25 deletions

View File

@@ -8,6 +8,7 @@ import {
normalizeUploadMaxTotalSize
} from '~/lib/upload-size-limit.js'
import {
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '~/lib/post-card-thumbnail-url.js'
@@ -90,6 +91,13 @@ const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avat
*/
const isPostCardThumbnailDiskItem = (item) => isPostCardThumbnailUrl(item?.url)
/**
* 페이지 커버 썸네일 디스크 경로 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 페이지 커버 썸네일이면 true
*/
const isPageCoverThumbnailDiskItem = (item) => isPageCoverThumbnailUrl(item?.url)
/**
* 게시물 본문 표시용 이미지 디스크 경로 여부
* @param {Object} item - 미디어 항목
@@ -121,7 +129,7 @@ const getPostBodyThumbnailResolutionLabel = (item) => {
* @param {Object} item - 미디어 항목
* @returns {boolean} 시스템 관리 항목이면 true
*/
const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item) || isPostBodyThumbnailDiskItem(item)
const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item) || isPageCoverThumbnailDiskItem(item) || isPostBodyThumbnailDiskItem(item)
/**
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
@@ -135,7 +143,7 @@ const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item
* @param {Object} item - 미디어 항목
* @returns {boolean} 변경 가능 여부
*/
const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)
const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item) && !isPageCoverThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)
/**
* 미디어 논리 폴더를 변경할 수 있는지 확인한다.
@@ -167,6 +175,10 @@ const getMediaBadgeLabel = (item) => {
return '카드'
}
if (item?.thumbnailStatus?.role === 'pageCover') {
return '페이지'
}
if (item?.bodyThumbnailStatus?.role === 'body') {
return '본문'
}
@@ -196,6 +208,10 @@ const getMediaBadgeClass = (item) => {
return 'bg-sky-700 text-white'
}
if (item?.thumbnailStatus?.role === 'pageCover') {
return 'bg-cyan-800 text-white'
}
if (item?.bodyThumbnailStatus?.role === 'body') {
return 'bg-violet-700 text-white'
}
@@ -226,6 +242,18 @@ const getMediaUsageBadgeCount = (item) => {
*/
const canRegeneratePostThumbnail = (item) => (
item?.thumbnailStatus?.role === 'original'
&& item?.thumbnailStatus?.variant !== 'pageCover'
&& (item.thumbnailStatus.isFallbackActive || item.thumbnailStatus.hasThumbnail)
)
/**
* 페이지 커버 썸네일을 생성할 수 있는 원본 이미지인지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 생성 가능 여부
*/
const canRegeneratePageCoverThumbnail = (item) => (
item?.thumbnailStatus?.role === 'original'
&& item?.thumbnailStatus?.variant === 'pageCover'
&& (item.thumbnailStatus.isFallbackActive || item.thumbnailStatus.hasThumbnail)
)
@@ -246,9 +274,9 @@ const canRegeneratePostBodyThumbnail = (item) => (
*/
const getMediaItemKind = (item) => item?.kind || 'image'
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item) && !isPostCardThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)))
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item) && !isPostCardThumbnailDiskItem(item) && !isPageCoverThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)))
const postThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostCardThumbnailDiskItem(item)))
const postThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostCardThumbnailDiskItem(item) || isPageCoverThumbnailDiskItem(item)))
const postBodyThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostBodyThumbnailDiskItem(item)))
@@ -298,7 +326,7 @@ const activeSystemMediaTitle = computed(() => {
const activeSystemMediaHint = computed(() => {
if (activeTab.value === 'postThumbnails') {
return '게시물 목록 카드에서 쓰는 자동 생성 이미지입니다. 원본 대표 이미지가 사용 중이고 이 썸네일 파일이 있으면 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 썸네일은 사용 중으로 표시되며, 썸네일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.'
return '게시물 목록 카드와 홈 Pages 진열장에서 쓰는 자동 생성 이미지입니다. 원본 이미지가 사용 중이고 이 썸네일 파일이 있으면 공개 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 썸네일은 사용 중으로 표시되며, 썸네일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.'
}
if (activeTab.value === 'postBodyThumbnails') {
@@ -1020,6 +1048,36 @@ const regeneratePostThumbnail = async (item) => {
}
}
/**
* 선택한 원본 이미지의 페이지 커버 썸네일을 다시 생성한다.
* @param {Object} item - 미디어 항목
* @returns {Promise<void>}
*/
const regeneratePageCoverThumbnail = async (item) => {
if (!canRegeneratePageCoverThumbnail(item)) {
return
}
regeneratingThumbnailUrl.value = item.url
try {
await $fetch('/admin/api/media/thumbnail', {
method: 'POST',
body: {
url: item.url,
kind: 'pageCover'
}
})
await refresh()
selectedMediaUrl.value = item.url
showToast('success', '페이지 커버 썸네일을 생성했습니다.')
} catch (error) {
showToast('error', error?.data?.message || '페이지 커버 썸네일을 생성하지 못했습니다.')
} finally {
regeneratingThumbnailUrl.value = ''
}
}
/**
* 선택한 원본 이미지의 본문 표시용 이미지를 다시 생성한다.
* @param {Object} item - 미디어 항목
@@ -1534,18 +1592,29 @@ watch(filteredMediaUrls, (urls) => {
<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>
<dt class="admin-media__info-label text-xs font-semibold text-muted">
{{ selectedMedia.thumbnailStatus.variant === 'pageCover' || selectedMedia.thumbnailStatus.role === 'pageCover' ? '페이지 커버 썸네일' : '카드 썸네일' }}
</dt>
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
<span v-if="selectedMedia.thumbnailStatus.role === 'card'">
자동 생성된 목록 카드용 썸네일입니다.
</span>
<span v-else-if="selectedMedia.thumbnailStatus.role === 'pageCover'">
자동 생성된 Pages 진열장용 커버 썸네일입니다.
</span>
<template v-else>
<span v-if="selectedMedia.thumbnailStatus.hasThumbnail">
생성됨: {{ selectedMedia.thumbnailStatus.thumbnailUrl }}
</span>
<span v-else-if="selectedMedia.thumbnailStatus.isFallbackActive && selectedMedia.thumbnailStatus.variant === 'pageCover'" class="font-semibold text-amber-700">
페이지 커버 썸네일 없음. 현재 Pages 진열장에서 원본 이미지를 불러옵니다.
</span>
<span v-else-if="selectedMedia.thumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
썸네일 없음. 현재 목록에서 원본 이미지를 불러옵니다.
</span>
<span v-else-if="selectedMedia.thumbnailStatus.variant === 'pageCover'">
페이지 커버 썸네일 없음. 홈 Pages 진열장에서 사용되면 원본으로 대체됩니다.
</span>
<span v-else>
썸네일 없음. 대표 이미지로 사용되면 원본으로 대체됩니다.
</span>
@@ -1562,6 +1631,15 @@ watch(filteredMediaUrls, (urls) => {
>
{{ regeneratingThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.thumbnailStatus.hasThumbnail ? '카드 썸네일 다시 생성' : '카드 썸네일 생성') }}
</button>
<button
v-if="canRegeneratePageCoverThumbnail(selectedMedia)"
class="admin-media__page-cover-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="regeneratingThumbnailUrl === selectedMedia.url"
@click="regeneratePageCoverThumbnail(selectedMedia)"
>
{{ regeneratingThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.thumbnailStatus.hasThumbnail ? '페이지 커버 썸네일 다시 생성' : '페이지 커버 썸네일 생성') }}
</button>
</dd>
</div>
<div v-if="selectedMedia.bodyThumbnailStatus" class="admin-media__info-row">

View File

@@ -567,7 +567,7 @@ const pageOrderRemovedCount = computed(() => pageOrderRemovedPageIds.value.size)
* @param {Object} page - 관리자 페이지
* @returns {string} 썸네일 URL
*/
const getPageOrderCardImage = (page) => String(page?.featuredImage || page?.listingImage || '').trim()
const getPageOrderCardImage = (page) => String(page?.listingImageThumbnail || page?.featuredImage || page?.listingImage || '').trim()
/**
* 페이지를 현재 섹션에서 제거 목록에 추가한다.