본문 이미지 최적화·라이트박스·번호형 페이지네이션 반영 (v1.10.3)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -95,15 +95,6 @@ const postCards = computed(() => (postsResponse.value?.items || []).map((post) =
|
||||
}
|
||||
}))
|
||||
|
||||
/**
|
||||
* 게시물 목록 페이지 링크를 만든다.
|
||||
* @param {number} page - 페이지 번호
|
||||
* @returns {Object} Nuxt 링크 대상
|
||||
*/
|
||||
const getPostsPageTo = (page) => ({
|
||||
path: '/posts',
|
||||
query: page > 1 ? { page } : {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -121,32 +112,13 @@ const getPostsPageTo = (page) => ({
|
||||
|
||||
<PostCard v-for="post in postCards" :key="post.to" :post="post" />
|
||||
|
||||
<nav
|
||||
<SitePagination
|
||||
v-if="pagination.totalPages > 1"
|
||||
class="posts-index__pagination site-section"
|
||||
aria-label="게시물 페이지"
|
||||
>
|
||||
<div class="site-section-body mx-auto flex max-w-[720px] items-center justify-between gap-3 text-sm">
|
||||
<NuxtLink
|
||||
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
|
||||
:class="pagination.page <= 1 ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getPostsPageTo(Math.max(1, pagination.page - 1))"
|
||||
:aria-disabled="pagination.page <= 1"
|
||||
>
|
||||
이전
|
||||
</NuxtLink>
|
||||
<span class="site-muted">
|
||||
{{ pagination.page }} / {{ pagination.totalPages }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
|
||||
:class="pagination.page >= pagination.totalPages ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getPostsPageTo(Math.min(pagination.totalPages, pagination.page + 1))"
|
||||
:aria-disabled="pagination.page >= pagination.totalPages"
|
||||
>
|
||||
다음
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
class="posts-index__pagination"
|
||||
:page="pagination.page"
|
||||
:total-pages="pagination.totalPages"
|
||||
label="게시물 페이지"
|
||||
base-path="/posts"
|
||||
/>
|
||||
</MainColumn>
|
||||
</template>
|
||||
|
||||
@@ -89,15 +89,6 @@ const tagPosts = computed(() => (postsResponse.value?.items || [])
|
||||
}
|
||||
}))
|
||||
|
||||
/**
|
||||
* 태그 게시물 목록 페이지 링크를 만든다.
|
||||
* @param {number} page - 페이지 번호
|
||||
* @returns {Object} Nuxt 링크 대상
|
||||
*/
|
||||
const getTagPageTo = (page) => ({
|
||||
path: `/tag/${slug.value}`,
|
||||
query: page > 1 ? { page } : {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -202,33 +193,14 @@ const getTagPageTo = (page) => ({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav
|
||||
<SitePagination
|
||||
v-if="pagination.totalPages > 1"
|
||||
class="tag-posts-pagination site-section"
|
||||
aria-label="태그 게시물 페이지"
|
||||
>
|
||||
<div class="site-section-body mx-auto flex max-w-[720px] items-center justify-between gap-3 text-sm">
|
||||
<NuxtLink
|
||||
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
|
||||
:class="pagination.page <= 1 ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getTagPageTo(Math.max(1, pagination.page - 1))"
|
||||
:aria-disabled="pagination.page <= 1"
|
||||
>
|
||||
이전
|
||||
</NuxtLink>
|
||||
<span class="site-muted">
|
||||
{{ pagination.page }} / {{ pagination.totalPages }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
|
||||
:class="pagination.page >= pagination.totalPages ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getTagPageTo(Math.min(pagination.totalPages, pagination.page + 1))"
|
||||
:aria-disabled="pagination.page >= pagination.totalPages"
|
||||
>
|
||||
다음
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
class="tag-posts-pagination"
|
||||
:page="pagination.page"
|
||||
:total-pages="pagination.totalPages"
|
||||
label="태그 게시물 페이지"
|
||||
:base-path="`/tag/${slug}`"
|
||||
/>
|
||||
|
||||
<section v-if="tagPosts.length === 0" class="tag-posts-empty site-section">
|
||||
<div class="tag-posts-empty__body site-section-body mx-auto max-w-[720px] text-sm site-muted">
|
||||
|
||||
Reference in New Issue
Block a user