사용자 이동과 미디어 선택 개선
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user