관리자 미디어 페이지네이션과 라이브 렌더링 개선

This commit is contained in:
2026-07-02 15:15:33 +09:00
parent 7c9b5e9278
commit 3dd30d7f76
12 changed files with 242 additions and 25 deletions

View File

@@ -8,6 +8,7 @@ import {
normalizeUploadMaxTotalSize
} from '~/lib/upload-size-limit.js'
import {
getPostBodyPreviewThumbnailUrl,
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl,
@@ -21,6 +22,9 @@ definePageMeta({
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
const MEDIA_THUMBNAIL_ROOT = '썸네일'
/** 관리자 미디어 한 페이지 표시 개수 옵션 */
const mediaPageSizeOptions = [50, 100, 200]
/** 회원 등급 코드 → 미디어 배지 라벨 */
const memberRoleBadgeLabels = {
owner: '소유자',
@@ -53,6 +57,8 @@ const regeneratingBodyThumbnailUrl = ref('')
const selectedMediaUrl = ref('')
const selectedMediaUrls = ref([])
const lastSelectedIndex = ref(-1)
const mediaPage = ref(1)
const mediaPageSize = ref(50)
const draggingUrls = ref([])
const uploadInputRef = ref(null)
const uploadingFiles = ref(false)
@@ -147,6 +153,48 @@ const getMediaPreviewUrl = (item) => {
return withGeneratedImageCacheVersion(url, `${item?.updatedAt || ''}-${item?.size || ''}`)
}
/**
* 관리자 미디어 그리드에서 사용할 가벼운 이미지 URL을 반환한다.
* @param {Object} item - 미디어 항목
* @returns {string} 그리드 표시 URL
*/
const getMediaGridPreviewUrl = (item) => {
if (getMediaItemKind(item) !== 'image') {
return getMediaPreviewUrl(item)
}
if (isSystemManagedMediaItem(item)) {
return getMediaPreviewUrl(item)
}
if (item?.bodyThumbnailStatus?.hasThumbnail) {
return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(item.url), `${item?.updatedAt || ''}-${item?.size || ''}`)
}
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`)
}
return getMediaPreviewUrl(item)
}
/**
* 그리드용 파생 이미지 로드 실패 시 원본 이미지로 되돌린다.
* @param {Event} event - 이미지 오류 이벤트
* @param {Object} item - 미디어 항목
* @returns {void}
*/
const onMediaGridImageError = (event, item) => {
const image = event.target
if (!(image instanceof HTMLImageElement) || image.dataset.fallbackApplied === 'true') {
return
}
image.dataset.fallbackApplied = 'true'
image.src = getMediaPreviewUrl(item)
}
/**
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
* @param {Object} item - 미디어 항목
@@ -544,7 +592,19 @@ const filteredMediaItems = computed(() => {
const filteredMediaUrls = computed(() => filteredMediaItems.value.map((item) => item.url))
const filteredSelectableMediaItems = computed(() => filteredMediaItems.value.filter((item) => !isMediaItemLocked(item)))
const mediaPageCount = computed(() => Math.max(1, Math.ceil(filteredMediaItems.value.length / mediaPageSize.value)))
const mediaPageStartIndex = computed(() => filteredMediaItems.value.length
? (mediaPage.value - 1) * mediaPageSize.value
: 0)
const mediaPageDisplayStart = computed(() => filteredMediaItems.value.length ? mediaPageStartIndex.value + 1 : 0)
const mediaPageEndIndex = computed(() => Math.min(mediaPageStartIndex.value + mediaPageSize.value, filteredMediaItems.value.length))
const paginatedMediaItems = computed(() => filteredMediaItems.value.slice(mediaPageStartIndex.value, mediaPageEndIndex.value))
const filteredSelectableMediaItems = computed(() => paginatedMediaItems.value.filter((item) => !isMediaItemLocked(item)))
const filteredSelectableMediaUrls = computed(() => filteredSelectableMediaItems.value.map((item) => item.url))
@@ -556,6 +616,64 @@ const isAllFilteredMediaSelected = computed(() => {
return filteredSelectableMediaUrls.value.every((url) => selectedMediaUrls.value.includes(url))
})
const mediaPaginationItems = computed(() => {
const total = mediaPageCount.value
const current = mediaPage.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const pages = new Set([1, total, current - 1, current, current + 1])
if (current <= 4) {
pages.add(2)
pages.add(3)
pages.add(4)
pages.add(5)
}
if (current >= total - 3) {
pages.add(total - 4)
pages.add(total - 3)
pages.add(total - 2)
pages.add(total - 1)
}
const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right)
return orderedPages.reduce((items, page, index) => {
const previousPage = orderedPages[index - 1]
if (previousPage && page - previousPage > 1) {
items.push(`ellipsis-${previousPage}-${page}`)
}
items.push(page)
return items
}, [])
})
/**
* 관리자 미디어 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {void}
*/
const setMediaPage = (page) => {
mediaPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPageCount.value)
lastSelectedIndex.value = -1
}
/**
* 관리자 미디어 페이지 표시 개수를 변경한다.
* @param {Event} event - select 변경 이벤트
* @returns {void}
*/
const setMediaPageSize = (event) => {
mediaPageSize.value = Number(event.target.value) || 50
mediaPage.value = 1
lastSelectedIndex.value = -1
}
const selectedDeletableMediaItems = computed(() => {
const selectedUrlSet = new Set(selectedMediaUrls.value)
@@ -624,7 +742,7 @@ const toggleMediaSelection = (item, index, event) => {
if (event.shiftKey && lastSelectedIndex.value >= 0) {
const startIndex = Math.min(lastSelectedIndex.value, index)
const endIndex = Math.max(lastSelectedIndex.value, index)
const rangeUrls = filteredMediaItems.value.slice(startIndex, endIndex + 1).map((mediaItem) => mediaItem.url)
const rangeUrls = paginatedMediaItems.value.slice(startIndex, endIndex + 1).map((mediaItem) => mediaItem.url)
const merged = new Set([...selectedMediaUrls.value, ...rangeUrls])
selectedMediaUrls.value = [...merged]
lastSelectedIndex.value = index
@@ -647,7 +765,7 @@ const clearMediaSelection = () => {
}
/**
* 현재 필터링된 미디어 전체 선택을 토글한다.
* 현재 페이지에 표시된 미디어 전체 선택을 토글한다.
* @returns {void}
*/
const toggleFilteredMediaSelection = () => {
@@ -666,7 +784,7 @@ const toggleFilteredMediaSelection = () => {
...selectedMediaUrls.value,
...filteredSelectableMediaUrls.value
])]
lastSelectedIndex.value = filteredMediaItems.value.length - 1
lastSelectedIndex.value = paginatedMediaItems.value.length - 1
}
/**
@@ -1179,6 +1297,18 @@ watch(filteredMediaUrls, (urls) => {
lastSelectedIndex.value = -1
}
})
watch([activeTab, activeFolder, activeMediaKind, showUnusedOnly, searchText], () => {
mediaPage.value = 1
lastSelectedIndex.value = -1
})
watch(mediaPageCount, (pageCount) => {
if (mediaPage.value > pageCount) {
mediaPage.value = pageCount
lastSelectedIndex.value = -1
}
})
</script>
<template>
@@ -1381,10 +1511,22 @@ watch(filteredMediaUrls, (urls) => {
{{ systemManagedTab ? activeSystemMediaTitle : (activeFolder || '전체 미디어') }}
</h2>
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
{{ filteredMediaItems.length }}개 표시
{{ filteredMediaItems.length }}개 중 {{ mediaPageDisplayStart }}-{{ mediaPageEndIndex }}개 표시
</p>
</div>
<div class="admin-media__selection-actions flex flex-wrap items-center justify-end gap-2">
<label v-if="filteredMediaItems.length" class="admin-media__page-size flex items-center gap-2 text-xs font-semibold text-muted">
<span>표시</span>
<select
class="admin-media__page-size-select rounded border border-line bg-white px-2 py-2 text-xs font-semibold text-ink outline-none transition focus:border-[#8e9cac]"
:value="mediaPageSize"
@change="setMediaPageSize"
>
<option v-for="size in mediaPageSizeOptions" :key="size" :value="size">
{{ size }}개
</option>
</select>
</label>
<button
v-if="filteredMediaItems.length"
class="admin-media__select-all rounded border border-line bg-white px-3 py-2 text-xs font-semibold text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-50"
@@ -1393,7 +1535,7 @@ watch(filteredMediaUrls, (urls) => {
:disabled="!filteredSelectableMediaUrls.length"
@click="toggleFilteredMediaSelection"
>
{{ isAllFilteredMediaSelected ? '전체 해제' : '전체 선택' }}
{{ isAllFilteredMediaSelected ? '현재 페이지 해제' : '현재 페이지 선택' }}
</button>
<div v-if="selectedMediaUrls.length" class="admin-media__selection flex flex-wrap items-center gap-2 rounded border border-line bg-white px-3 py-2 text-xs">
<strong class="admin-media__selection-count text-ink">{{ selectedMediaUrls.length }}개 선택됨</strong>
@@ -1414,7 +1556,7 @@ watch(filteredMediaUrls, (urls) => {
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
<div
v-for="(item, index) in filteredMediaItems"
v-for="(item, index) in paginatedMediaItems"
:key="item.url"
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]"
:draggable="activeTab === 'library'"
@@ -1453,8 +1595,11 @@ watch(filteredMediaUrls, (urls) => {
<img
v-if="getMediaItemKind(item) === 'image'"
class="admin-media__image aspect-square w-full bg-surface object-cover"
:src="getMediaPreviewUrl(item)"
:src="getMediaGridPreviewUrl(item)"
:alt="item.title"
loading="lazy"
decoding="async"
@error="onMediaGridImageError($event, item)"
>
<AdminMediaVideoThumbnail
v-else-if="getMediaItemKind(item) === 'video'"
@@ -1490,6 +1635,50 @@ watch(filteredMediaUrls, (urls) => {
</div>
</div>
<nav
v-if="mediaPageCount > 1"
class="admin-media__pagination mt-6 flex flex-wrap items-center justify-center gap-2"
aria-label="미디어 페이지"
>
<button
class="admin-media__pagination-arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="mediaPage <= 1"
aria-label="이전 페이지"
@click="setMediaPage(mediaPage - 1)"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<template v-for="item in mediaPaginationItems" :key="item">
<span v-if="typeof item === 'string'" class="admin-media__pagination-ellipsis px-2 text-sm font-semibold text-muted">
...
</span>
<button
v-else
class="admin-media__pagination-page grid size-9 place-items-center rounded-full text-sm font-semibold transition"
:class="item === mediaPage ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-white hover:text-ink'"
type="button"
:aria-current="item === mediaPage ? 'page' : undefined"
@click="setMediaPage(item)"
>
{{ item }}
</button>
</template>
<button
class="admin-media__pagination-arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="mediaPage >= mediaPageCount"
aria-label="다음 페이지"
@click="setMediaPage(mediaPage + 1)"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</nav>
<p v-else class="admin-media__empty mt-5 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
표시할 미디어가 없습니다.
</p>