diff --git a/app.vue b/app.vue index b8ce315..002e13b 100644 --- a/app.vue +++ b/app.vue @@ -18,6 +18,31 @@ const siteAccentStyle = computed(() => ({ '--site-accent': siteAccentColor.value })) +const isSitePageNavigating = ref(false) + +if (import.meta.client) { + const nuxtApp = useNuxtApp() + + /** + * 공개 페이지 이동 로딩 오버레이를 표시한다. + * @returns {void} + */ + const showSitePageNavigationOverlay = () => { + isSitePageNavigating.value = true + } + + /** + * 공개 페이지 이동 로딩 오버레이를 숨긴다. + * @returns {void} + */ + const hideSitePageNavigationOverlay = () => { + isSitePageNavigating.value = false + } + + nuxtApp.hook('page:loading:start', showSitePageNavigationOverlay) + nuxtApp.hook('page:loading:end', hideSitePageNavigationOverlay) +} + useHead(() => ({ titleTemplate: (titleChunk) => titleChunk || appSiteSettings.value.title, link: appSiteSettings.value.faviconUrl @@ -35,8 +60,40 @@ useHead(() => ({ + + diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 89dd322..2d0e3ba 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -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} 표시할 미디어 목록 */ -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} 필터링된 미디어 목록 + * 미디어 선택 모달 페이지를 변경한다. + * @param {number} page - 이동할 페이지 + * @returns {Promise} */ -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) => { -