diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index a726d5f..2d24150 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -5,13 +5,6 @@ import { toAdminPostStoredTitle } from '../../lib/admin-post-title.js' import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js' -import { - getPostBodyPreviewThumbnailUrl, - isPageCoverThumbnailUrl, - isPostBodyThumbnailUrl, - isPostCardThumbnailUrl, - withGeneratedImageCacheVersion -} from '../../lib/post-card-thumbnail-url.js' import { DEFAULT_POST_TAG_LIMIT } from '../../lib/post-tag-limit.js' const props = defineProps({ @@ -66,29 +59,8 @@ const blockEditor = ref(null) const editorBlockPanel = ref({ open: false, panel: null }) /** 본문 에디터 모드: write | preview */ const editorMode = ref('write') -const mediaItems = ref([]) -const isMediaPickerOpen = ref(false) -const mediaPickerTarget = ref('featuredImage') -const isLoadingMedia = ref(false) -const isUploadingFeaturedImage = ref(false) const isSettingsOpen = ref(true) const isTitleInputComposing = ref(false) -const activeMediaPickerTab = ref('upload') -const selectedMediaPickerUrl = ref('') -const mediaSearchQuery = ref('') -const { - displayValue: mediaSearchInput, - onInput: onMediaSearchInput, - onCompositionEnd: onMediaSearchCompositionEnd -} = useImeSearchInput(mediaSearchQuery, { debounce: 250 }) -const mediaPickerPage = ref(1) -const mediaPickerPageSize = 50 -const mediaPickerPagination = ref({ - page: 1, - pageSize: mediaPickerPageSize, - total: 0, - pageCount: 1 -}) const savedPostSnapshot = ref('') const failedDraftAutosaveSnapshot = ref('') const slugFieldError = ref('') @@ -109,15 +81,6 @@ const { data: siteSettings } = useFetch('/admin/api/settings', { /** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */ const READING_CHARS_PER_MINUTE = 600 -/** - * 자동 생성 파생 이미지 URL인지 확인한다. - * @param {string|null|undefined} imageUrl - 검사할 이미지 URL - * @returns {boolean} 자동 생성 이미지이면 true - */ -const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl) - || isPostBodyThumbnailUrl(imageUrl) - || isPageCoverThumbnailUrl(imageUrl) - /** * ISO 날짜를 datetime-local 입력값으로 변환 * @param {string} value - ISO 날짜 문자열 @@ -469,6 +432,34 @@ const { siteSettings }) +const { + activeMediaPickerTab, + applyPickedImage, + closeMediaPicker, + dropFeaturedImage, + featuredImageMediaItems, + getFeaturedMediaPreviewUrl, + isLoadingMedia, + isMediaPickerOpen, + isUploadingFeaturedImage, + mediaPickerPage, + mediaPickerPageCount, + mediaPickerPageItems, + mediaPickerRangeLabel, + mediaSearchInput, + mediaSearchQuery, + onMediaSearchCompositionEnd, + onMediaSearchInput, + openMediaPicker, + removeFeaturedImage, + selectPickedImage, + selectedMediaPickerUrl, + setMediaPickerPage, + uploadFeaturedImage +} = useAdminFeaturedImagePicker({ + form +}) + /** * 예약 발행 여부 확인 * @returns {boolean} 예약 발행 여부 @@ -722,272 +713,6 @@ const { onLeaveConfirmed: clearAutosaveLocal }) -/** - * 미디어 라이브러리 목록 조회 - * @returns {Promise} - */ -const fetchMediaItems = async () => { - isLoadingMedia.value = true - - try { - const response = await $fetch('/admin/api/media', { - query: { - paged: '1', - tab: 'library', - page: mediaPickerPage.value, - pageSize: mediaPickerPageSize, - kind: 'image', - 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 - } -} - -/** - * 대표 이미지 선택 창 열기 - * @returns {Promise} - */ -const openMediaPicker = async (target = 'featuredImage') => { - mediaPickerTarget.value = target - activeMediaPickerTab.value = 'upload' - mediaSearchQuery.value = '' - mediaPickerPage.value = 1 - mediaPickerPagination.value = { - page: 1, - pageSize: mediaPickerPageSize, - total: 0, - pageCount: 1 - } - selectedMediaPickerUrl.value = isGeneratedThumbnailUrl(form[target]) ? '' : form[target] || '' - isMediaPickerOpen.value = true -} - -/** - * 대표 이미지 선택 창 닫기 - * @returns {void} - */ -const closeMediaPicker = () => { - if (isUploadingFeaturedImage.value) { - return - } - - isMediaPickerOpen.value = false - mediaItems.value = [] - mediaSearchQuery.value = '' - mediaPickerPage.value = 1 - mediaPickerPagination.value = { - page: 1, - pageSize: mediaPickerPageSize, - total: 0, - pageCount: 1 - } -} - -/** - * 대표 이미지로 선택 가능한 원본 이미지 목록 - * @returns {Array} 자동 생성 이미지를 제외한 이미지 목록 - */ -const featuredImageMediaItems = computed(() => mediaItems.value.filter((item) => ( - item?.kind === 'image' && !isGeneratedThumbnailUrl(item.url) -))) - -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개' - } - - 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 - }, []) -}) - -/** - * 대표 이미지 선택 모달 페이지를 변경한다. - * @param {number} page - 이동할 페이지 - * @returns {Promise} - */ -const setMediaPickerPage = async (page) => { - mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value) - await fetchMediaItems() -} - -/** - * 대표 이미지 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다. - * 선택 값은 원본 URL을 유지하고, 화면 표시만 파생 이미지를 우선 사용한다. - * @param {Object} item - 미디어 항목 - * @returns {string} 미리보기 URL - */ -const getFeaturedMediaPreviewUrl = (item) => { - const url = String(item?.url || '') - - if (item?.bodyThumbnailStatus?.hasThumbnail) { - return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(url), `${item?.updatedAt || ''}-${item?.size || ''}`) - } - - if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) { - return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`) - } - - return url -} - -watch(mediaSearchQuery, async () => { - if (!isMediaPickerOpen.value || activeMediaPickerTab.value !== 'library') { - return - } - - mediaPickerPage.value = 1 - await fetchMediaItems() -}) - -watch(activeMediaPickerTab, async (tab) => { - if (!isMediaPickerOpen.value || tab !== 'library') { - return - } - - mediaPickerPage.value = 1 - await fetchMediaItems() -}) - -/** - * 대표 이미지 선택 상태 변경 - * @param {Object} item - 미디어 항목 - * @returns {void} - */ -const selectPickedImage = (item) => { - selectedMediaPickerUrl.value = item.url -} - -/** - * 선택한 대표 이미지 적용 - * @returns {void} - */ -const applyPickedImage = () => { - if (!selectedMediaPickerUrl.value || isGeneratedThumbnailUrl(selectedMediaPickerUrl.value)) { - return - } - - form[mediaPickerTarget.value] = selectedMediaPickerUrl.value - closeMediaPicker() -} - -/** - * 대표 이미지 삭제 - * @returns {void} - */ -const removeFeaturedImage = () => { - form.featuredImage = '' -} - -/** - * 대표 이미지 파일 업로드 - * @param {File} file - 업로드 파일 - * @returns {Promise} - */ -const uploadFeaturedImageFile = async (file) => { - const formData = new FormData() - formData.append('files', file) - isUploadingFeaturedImage.value = true - - try { - const result = await $fetch('/admin/api/uploads', { - method: 'POST', - body: formData - }) - selectedMediaPickerUrl.value = result.files?.[0]?.url || '' - await fetchMediaItems() - activeMediaPickerTab.value = 'library' - } finally { - isUploadingFeaturedImage.value = false - } -} - -/** - * 대표 이미지 파일 입력 처리 - * @param {Event} event - 파일 입력 이벤트 - * @returns {Promise} - */ -const uploadFeaturedImage = async (event) => { - const files = event.target.files - - if (!files?.length) { - return - } - - try { - await uploadFeaturedImageFile(files[0]) - } finally { - event.target.value = '' - } -} - -/** - * 대표 이미지 드롭 업로드 - * @param {DragEvent} event - 드롭 이벤트 - * @returns {Promise} - */ -const dropFeaturedImage = async (event) => { - const files = event.dataTransfer?.files - - if (!files?.length) { - return - } - - await uploadFeaturedImageFile(files[0]) -} - /** * 날짜·시간 입력의 네이티브 선택 UI를 연다. * @param {MouseEvent} event - 클릭 이벤트 diff --git a/composables/useAdminFeaturedImagePicker.js b/composables/useAdminFeaturedImagePicker.js new file mode 100644 index 0000000..b938c79 --- /dev/null +++ b/composables/useAdminFeaturedImagePicker.js @@ -0,0 +1,340 @@ +import { computed, ref, watch } from 'vue' +import { + getPostBodyPreviewThumbnailUrl, + isPageCoverThumbnailUrl, + isPostBodyThumbnailUrl, + isPostCardThumbnailUrl, + withGeneratedImageCacheVersion +} from '../lib/post-card-thumbnail-url.js' + +/** + * 자동 생성 파생 이미지 URL인지 확인한다. + * @param {string|null|undefined} imageUrl - 검사할 이미지 URL + * @returns {boolean} 자동 생성 이미지이면 true + */ +const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl) + || isPostBodyThumbnailUrl(imageUrl) + || isPageCoverThumbnailUrl(imageUrl) + +/** + * 관리자 게시물 폼의 대표 이미지 선택 모달 상태를 만든다. + * @param {Object} options - 대표 이미지 선택 옵션 + * @param {Object} options.form - 게시물 폼 reactive 객체 + * @param {number} [options.pageSize=50] - 미디어 페이지 크기 + * @returns {Object} 대표 이미지 선택 상태와 이벤트 처리기 + */ +export const useAdminFeaturedImagePicker = ({ + form, + pageSize = 50 +}) => { + const mediaItems = ref([]) + const isMediaPickerOpen = ref(false) + const mediaPickerTarget = ref('featuredImage') + const isLoadingMedia = ref(false) + const isUploadingFeaturedImage = ref(false) + const activeMediaPickerTab = ref('upload') + const selectedMediaPickerUrl = ref('') + const mediaSearchQuery = ref('') + const { + displayValue: mediaSearchInput, + onInput: onMediaSearchInput, + onCompositionEnd: onMediaSearchCompositionEnd + } = useImeSearchInput(mediaSearchQuery, { debounce: 250 }) + const mediaPickerPage = ref(1) + const mediaPickerPageSize = pageSize + const mediaPickerPagination = ref({ + page: 1, + pageSize: mediaPickerPageSize, + total: 0, + pageCount: 1 + }) + + /** + * 미디어 선택 페이지네이션을 초기값으로 되돌린다. + * @returns {void} + */ + const resetMediaPickerPagination = () => { + mediaPickerPage.value = 1 + mediaPickerPagination.value = { + page: 1, + pageSize: mediaPickerPageSize, + total: 0, + pageCount: 1 + } + } + + /** + * 미디어 라이브러리 목록을 조회한다. + * @returns {Promise} + */ + const fetchMediaItems = async () => { + isLoadingMedia.value = true + + try { + const response = await $fetch('/admin/api/media', { + query: { + paged: '1', + tab: 'library', + page: mediaPickerPage.value, + pageSize: mediaPickerPageSize, + kind: 'image', + 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 + } + } + + /** + * 대표 이미지 선택 창을 연다. + * @param {string} target - 선택 값을 적용할 폼 필드 + * @returns {Promise} + */ + const openMediaPicker = async (target = 'featuredImage') => { + mediaPickerTarget.value = target + activeMediaPickerTab.value = 'upload' + mediaSearchQuery.value = '' + resetMediaPickerPagination() + selectedMediaPickerUrl.value = isGeneratedThumbnailUrl(form[target]) ? '' : form[target] || '' + isMediaPickerOpen.value = true + } + + /** + * 대표 이미지 선택 창을 닫는다. + * @returns {void} + */ + const closeMediaPicker = () => { + if (isUploadingFeaturedImage.value) { + return + } + + isMediaPickerOpen.value = false + mediaItems.value = [] + mediaSearchQuery.value = '' + resetMediaPickerPagination() + } + + const featuredImageMediaItems = computed(() => mediaItems.value.filter((item) => ( + item?.kind === 'image' && !isGeneratedThumbnailUrl(item.url) + ))) + + 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개' + } + + const page = Math.min(Math.max(Number(mediaPickerPagination.value.page) || mediaPickerPage.value, 1), mediaPickerPageCount.value) + const start = ((page - 1) * mediaPickerPageSize) + 1 + const end = Math.min(start + mediaPickerPageSize - 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 + }, []) + }) + + /** + * 대표 이미지 선택 모달 페이지를 변경한다. + * @param {number} page - 이동할 페이지 + * @returns {Promise} + */ + const setMediaPickerPage = async (page) => { + mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value) + await fetchMediaItems() + } + + /** + * 대표 이미지 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다. + * @param {Object} item - 미디어 항목 + * @returns {string} 미리보기 URL + */ + const getFeaturedMediaPreviewUrl = (item) => { + const url = String(item?.url || '') + + if (item?.bodyThumbnailStatus?.hasThumbnail) { + return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(url), `${item?.updatedAt || ''}-${item?.size || ''}`) + } + + if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) { + return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`) + } + + return url + } + + watch(mediaSearchQuery, async () => { + if (!isMediaPickerOpen.value || activeMediaPickerTab.value !== 'library') { + return + } + + mediaPickerPage.value = 1 + await fetchMediaItems() + }) + + watch(activeMediaPickerTab, async (tab) => { + if (!isMediaPickerOpen.value || tab !== 'library') { + return + } + + mediaPickerPage.value = 1 + await fetchMediaItems() + }) + + /** + * 대표 이미지 선택 상태를 변경한다. + * @param {Object} item - 미디어 항목 + * @returns {void} + */ + const selectPickedImage = (item) => { + selectedMediaPickerUrl.value = item.url + } + + /** + * 선택한 대표 이미지를 적용한다. + * @returns {void} + */ + const applyPickedImage = () => { + if (!selectedMediaPickerUrl.value || isGeneratedThumbnailUrl(selectedMediaPickerUrl.value)) { + return + } + + form[mediaPickerTarget.value] = selectedMediaPickerUrl.value + closeMediaPicker() + } + + /** + * 대표 이미지를 삭제한다. + * @returns {void} + */ + const removeFeaturedImage = () => { + form.featuredImage = '' + } + + /** + * 대표 이미지 파일을 업로드한다. + * @param {File} file - 업로드 파일 + * @returns {Promise} + */ + const uploadFeaturedImageFile = async (file) => { + const formData = new FormData() + formData.append('files', file) + isUploadingFeaturedImage.value = true + + try { + const result = await $fetch('/admin/api/uploads', { + method: 'POST', + body: formData + }) + selectedMediaPickerUrl.value = result.files?.[0]?.url || '' + await fetchMediaItems() + activeMediaPickerTab.value = 'library' + } finally { + isUploadingFeaturedImage.value = false + } + } + + /** + * 대표 이미지 파일 입력을 처리한다. + * @param {Event} event - 파일 입력 이벤트 + * @returns {Promise} + */ + const uploadFeaturedImage = async (event) => { + const files = event.target.files + + if (!files?.length) { + return + } + + try { + await uploadFeaturedImageFile(files[0]) + } finally { + event.target.value = '' + } + } + + /** + * 대표 이미지 드롭 업로드를 처리한다. + * @param {DragEvent} event - 드롭 이벤트 + * @returns {Promise} + */ + const dropFeaturedImage = async (event) => { + const files = event.dataTransfer?.files + + if (!files?.length) { + return + } + + await uploadFeaturedImageFile(files[0]) + } + + return { + activeMediaPickerTab, + applyPickedImage, + closeMediaPicker, + dropFeaturedImage, + featuredImageMediaItems, + getFeaturedMediaPreviewUrl, + isLoadingMedia, + isMediaPickerOpen, + isUploadingFeaturedImage, + mediaPickerPage, + mediaPickerPageCount, + mediaPickerPageItems, + mediaPickerRangeLabel, + mediaSearchInput, + mediaSearchQuery, + onMediaSearchCompositionEnd, + onMediaSearchInput, + openMediaPicker, + removeFeaturedImage, + selectPickedImage, + selectedMediaPickerUrl, + setMediaPickerPage, + uploadFeaturedImage + } +} diff --git a/docs/map.md b/docs/map.md index 3945944..357f912 100644 --- a/docs/map.md +++ b/docs/map.md @@ -23,6 +23,7 @@ | composables/useImeSearchInput.js | 한글 등 IME 조합 중(`compositionupdate`)에도 검색어가 즉시 반영되는 검색 입력 처리기(표시값·`onInput`·`onCompositionEnd`, NFC 정규화·선택적 디바운스) | | composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 | | composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 | +| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 | ## 공유 라이브러리(서버·클라이언트 공통) diff --git a/docs/todo.md b/docs/todo.md index 89e4a0c..bbb4a21 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -5,7 +5,7 @@ - 거대 컴포넌트 책임 분리 진행. - `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리. - `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리. - - `components/admin/AdminPostForm.vue`: 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리. + - `components/admin/AdminPostForm.vue`: 발행/초안 저장 상태, 우측 설정 패널 책임을 분리. - `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리. - 미디어 카탈로그 전환 코드 수명 정리. - 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토. diff --git a/docs/update.md b/docs/update.md index 58c996a..3a02804 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.11.41 + +- 관리자 게시물 폼의 대표 이미지 선택·검색·페이지네이션·업로드 로직을 `useAdminFeaturedImagePicker` composable로 분리. +- 대표 이미지 모달 상태와 미디어 API 조회 흐름을 게시물 폼 외부에서 관리하도록 정리. +- 글쓰기 폼의 스크립트 책임을 줄이고 대표 이미지 선택 UI 연결만 남김. + ## v1.11.40 - 관리자 게시물 폼의 태그 파싱·추천·선택·삭제 로직을 `useAdminPostTags` composable로 분리. diff --git a/package-lock.json b/package-lock.json index ba7cd5c..2c45155 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.40", + "version": "1.11.41", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.40", + "version": "1.11.41", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index ffabb6b..370ba05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.40", + "version": "1.11.41", "private": true, "type": "module", "imports": {