사용자 이동과 미디어 선택 개선

This commit is contained in:
2026-07-06 11:29:12 +09:00
parent 8a774a1874
commit 9952a05ec2
12 changed files with 335 additions and 63 deletions

View File

@@ -102,8 +102,17 @@ const {
displayValue: mediaSearchInput,
onInput: onMediaSearchInput,
onCompositionEnd: onMediaSearchCompositionEnd
} = useImeSearchInput(mediaSearchQuery)
} = useImeSearchInput(mediaSearchQuery, { debounce: 250 })
const mediaPickerPage = ref(1)
const mediaPickerPageSize = 50
const mediaPickerPagination = ref({
page: 1,
pageSize: mediaPickerPageSize,
total: 0,
pageCount: 1
})
const selectedMediaUrls = ref([])
const selectedMediaItemMap = ref({})
const lastSelectionState = ref({
start: 0,
end: 0
@@ -160,6 +169,18 @@ const mediaPickerAccept = computed(() => {
return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx'
})
const mediaPickerKind = computed(() => {
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return 'image'
}
if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
return mediaPickerTarget.value
}
return 'all'
})
/** 작성 textarea 최소 높이(px) */
const MIN_TEXTAREA_HEIGHT_PX = 620
@@ -3084,7 +3105,25 @@ const fetchMediaItems = async () => {
isLoadingMedia.value = true
try {
mediaItems.value = await $fetch('/admin/api/media')
const response = await $fetch('/admin/api/media', {
query: {
paged: '1',
tab: 'library',
page: mediaPickerPage.value,
pageSize: mediaPickerPageSize,
kind: mediaPickerKind.value,
search: mediaSearchQuery.value.trim() || undefined
}
})
mediaItems.value = response?.items || []
mediaPickerPagination.value = response?.pagination || {
page: 1,
pageSize: mediaPickerPageSize,
total: mediaItems.value.length,
pageCount: 1
}
mediaPickerPage.value = mediaPickerPagination.value.page || 1
} finally {
isLoadingMedia.value = false
}
@@ -3103,9 +3142,16 @@ const openMediaPicker = async (target) => {
mediaPickerTarget.value = target
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaPickerPagination.value = {
page: 1,
pageSize: mediaPickerPageSize,
total: 0,
pageCount: 1
}
selectedMediaUrls.value = []
selectedMediaItemMap.value = {}
isMediaPickerOpen.value = true
await fetchMediaItems()
}
defineExpose({
@@ -3139,39 +3185,103 @@ const closeMediaPicker = () => {
isMediaPickerOpen.value = false
selectedMediaUrls.value = []
selectedMediaItemMap.value = {}
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaItems.value = []
mediaPickerPagination.value = {
page: 1,
pageSize: mediaPickerPageSize,
total: 0,
pageCount: 1
}
liveSlashInsertLine.value = null
}
/**
* 미디어 항목이 검색어와 일치하는지 확인한다.
* @param {Object} item - 미디어 항목
* @param {string} query - 소문자 검색어
* @returns {boolean} 일치 여부
* 현재 미디어 선택 페이지의 표시 목록
* @returns {Array<Object>} 표시할 미디어 목록
*/
const mediaItemMatchesQuery = (item, query) => {
if (!query) {
return true
const filteredMediaItems = computed(() => mediaItems.value.filter(isMediaItemSelectableForTarget))
const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1))
const mediaPickerRangeLabel = computed(() => {
const total = Number(mediaPickerPagination.value.total) || 0
if (total <= 0) {
return '0개'
}
return [item.name, item.title, item.url]
.some((value) => String(value || '').toLowerCase().includes(query))
}
const page = Math.min(Math.max(Number(mediaPickerPagination.value.page) || mediaPickerPage.value, 1), mediaPickerPageCount.value)
const pageSize = Number(mediaPickerPagination.value.pageSize) || mediaPickerPageSize
const start = ((page - 1) * pageSize) + 1
const end = Math.min(start + pageSize - 1, total)
return `${total}개 중 ${start}-${end}개`
})
const mediaPickerPageItems = computed(() => {
const total = mediaPickerPageCount.value
const current = Math.min(Math.max(Number(mediaPickerPage.value) || 1, 1), total)
const pages = new Set([1, total, current])
for (let page = current - 2; page <= current + 2; page += 1) {
pages.add(page)
}
if (current <= 3) {
pages.add(2)
pages.add(3)
pages.add(4)
}
if (current >= total - 2) {
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
}, [])
})
/**
* 검색어로 필터링된 미디어 목록
* @returns {Array<Object>} 필터링된 미디어 목록
* 미디어 선택 모달 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {Promise<void>}
*/
const filteredMediaItems = computed(() => {
const query = mediaSearchQuery.value.trim().toLowerCase()
const targetItems = mediaItems.value.filter(isMediaItemSelectableForTarget)
const setMediaPickerPage = async (page) => {
mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value)
await fetchMediaItems()
}
if (!query) {
return targetItems
watch(mediaSearchQuery, async () => {
if (!isMediaPickerOpen.value || activeMediaPickerTab.value !== 'library') {
return
}
return targetItems.filter((item) => mediaItemMatchesQuery(item, query))
mediaPickerPage.value = 1
await fetchMediaItems()
})
watch(activeMediaPickerTab, async (tab) => {
if (!isMediaPickerOpen.value || tab !== 'library') {
return
}
mediaPickerPage.value = 1
await fetchMediaItems()
})
/**
@@ -3182,12 +3292,25 @@ const filteredMediaItems = computed(() => {
const toggleMediaSelection = (mediaItem) => {
if (!isGalleryMediaPicker.value) {
selectedMediaUrls.value = [mediaItem.url]
selectedMediaItemMap.value = {
[mediaItem.url]: mediaItem
}
return
}
selectedMediaUrls.value = selectedMediaUrls.value.includes(mediaItem.url)
? selectedMediaUrls.value.filter((url) => url !== mediaItem.url)
: [...selectedMediaUrls.value, mediaItem.url]
if (selectedMediaUrls.value.includes(mediaItem.url)) {
selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => url !== mediaItem.url)
const nextMap = { ...selectedMediaItemMap.value }
delete nextMap[mediaItem.url]
selectedMediaItemMap.value = nextMap
return
}
selectedMediaUrls.value = [...selectedMediaUrls.value, mediaItem.url]
selectedMediaItemMap.value = {
...selectedMediaItemMap.value,
[mediaItem.url]: mediaItem
}
}
/**
@@ -3267,7 +3390,7 @@ const insertSelectedMediaItems = (selectedItems) => {
*/
const applyMediaSelection = () => {
const selectedItems = selectedMediaUrls.value
.map((url) => mediaItems.value.find((item) => item.url === url))
.map((url) => selectedMediaItemMap.value[url] || mediaItems.value.find((item) => item.url === url))
.filter(Boolean)
insertSelectedMediaItems(selectedItems)
@@ -3381,7 +3504,9 @@ const uploadAndInsert = async (files, target = 'image') => {
insertBlockSnippet(createMediaBlockMarkdown(target, uploadedFiles[0]).join('\n'))
}
mediaItems.value = await $fetch('/admin/api/media').catch(() => mediaItems.value)
if (isMediaPickerOpen.value && activeMediaPickerTab.value === 'library') {
await fetchMediaItems()
}
} catch (error) {
showToast('error', resolveUploadFetchErrorMessage(error))
} finally {
@@ -3414,19 +3539,29 @@ const uploadFromMediaModal = async (files) => {
try {
const uploadedFiles = await uploadMediaFiles(files)
mediaItems.value = await $fetch('/admin/api/media').catch(() => [
...uploadedFiles,
...mediaItems.value
])
const uploadedUrls = uploadedFiles
.filter(isMediaItemSelectableForTarget)
.map((file) => file.url)
selectedMediaItemMap.value = uploadedFiles.reduce((map, file) => {
if (uploadedUrls.includes(file.url)) {
map[file.url] = file
}
return map
}, {})
selectedMediaUrls.value = isGalleryMediaPicker.value
? uploadedUrls
: uploadedUrls.slice(0, 1)
activeMediaPickerTab.value = 'library'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
await fetchMediaItems().catch(() => {
mediaItems.value = [
...uploadedFiles,
...mediaItems.value
]
})
showToast('success', '업로드가 완료되어 파일이 선택되었습니다.')
} catch (error) {
showToast('error', resolveUploadFetchErrorMessage(error))
@@ -3898,23 +4033,61 @@ const handleKeydown = (event) => {
</div>
</div>
<footer v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-footer flex items-center justify-end gap-2 border-t border-[#e3e6e8] px-5 py-4">
<button
class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="closeMediaPicker"
>
취소
</button>
<button
class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading || selectedMediaUrls.length === 0"
@click="applyMediaSelection"
>
삽입
</button>
<footer v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-footer flex flex-wrap items-center justify-between gap-3 border-t border-[#e3e6e8] px-5 py-4">
<div class="admin-markdown-editor__media-pagination flex flex-wrap items-center gap-2 text-xs font-semibold text-[#6b7280]">
<span>{{ mediaPickerRangeLabel }}</span>
<nav v-if="mediaPickerPageCount > 1" class="flex flex-wrap items-center gap-1" aria-label="미디어 선택 페이지">
<button
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoadingMedia || mediaPickerPage <= 1"
aria-label="이전 페이지"
@click="setMediaPickerPage(mediaPickerPage - 1)"
>
‹
</button>
<template v-for="item in mediaPickerPageItems" :key="item">
<span v-if="typeof item === 'string'" class="px-1 text-[#8e9cac]">...</span>
<button
v-else
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full text-xs font-semibold transition"
:class="item === mediaPickerPage ? 'bg-[#15171a] text-white' : 'text-[#8e9cac] hover:bg-[#f1f3f5] hover:text-[#394047]'"
type="button"
:disabled="isLoadingMedia"
@click="setMediaPickerPage(item)"
>
{{ item }}
</button>
</template>
<button
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoadingMedia || mediaPickerPage >= mediaPickerPageCount"
aria-label="다음 페이지"
@click="setMediaPickerPage(mediaPickerPage + 1)"
>
›
</button>
</nav>
</div>
<div class="admin-markdown-editor__media-actions flex items-center justify-end gap-2">
<button
class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="closeMediaPicker"
>
취소
</button>
<button
class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading || selectedMediaUrls.length === 0"
@click="applyMediaSelection"
>
삽입
</button>
</div>
</footer>
</div>
</div>