관리자 미디어 페이지네이션과 라이브 렌더링 개선
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user