diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index ce21fbd..fc48ffc 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -26,9 +26,11 @@ import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OP import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js' import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js' import { + getPostBodyPreviewThumbnailUrl, isPageCoverThumbnailUrl, isPostBodyThumbnailUrl, - isPostCardThumbnailUrl + isPostCardThumbnailUrl, + withGeneratedImageCacheVersion } from '../../lib/post-card-thumbnail-url.js' import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js' import { @@ -1840,6 +1842,47 @@ const isMediaItemSelectableForTarget = (item) => { return kind === mediaPickerTarget.value } +/** + * 미디어 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다. + * 선택·삽입 값은 원본 URL을 유지하고, 화면 표시만 파생 이미지를 우선 사용한다. + * @param {Object} item - 미디어 항목 + * @returns {string} 미리보기 URL + */ +const getMediaPickerPreviewUrl = (item) => { + const url = String(item?.url || '') + + if (getMediaItemKind(item) !== 'image') { + return 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 +} + +/** + * 미디어 선택 모달 파생 이미지 로드 실패 시 원본 URL로 되돌린다. + * @param {Event} event - 이미지 오류 이벤트 + * @param {Object} item - 미디어 항목 + * @returns {void} + */ +const onMediaPickerImageError = (event, item) => { + const image = event.target + + if (!(image instanceof HTMLImageElement) || image.dataset.fallbackApplied === 'true') { + return + } + + image.dataset.fallbackApplied = 'true' + image.src = String(item?.url || '') +} + /** * 드롭된 파일이 현재 미디어 선택 대상에 맞는지 확인한다. * @param {File} file - 브라우저 파일 @@ -3775,8 +3818,11 @@ const handleKeydown = (event) => { } ownerByDerivativeUrl - 파생 URL별 원본 URL 맵 + * @returns {Array} 파생 URL 목록 + */ +const getOwnedDerivativeUrls = (originalUrl, ownerByDerivativeUrl) => [...ownerByDerivativeUrl.entries()] + .filter(([, ownerUrl]) => ownerUrl === originalUrl) + .map(([derivativeUrl]) => derivativeUrl) + +/** + * 원본 이미지에 연결된 파생 이미지 사용처를 조회한다. + * @param {string} originalUrl - 원본 미디어 URL + * @param {Map} ownerByDerivativeUrl - 파생 URL별 원본 URL 맵 + * @param {Array} posts - 게시물 목록 + * @param {Array} pages - 페이지 목록 + * @param {Object} siteSettings - 사이트 설정 + * @param {Array} tags - 태그 목록 + * @param {{ location: string, label: string, onlyLocation?: string }} options - 사용처 표시 옵션 + * @returns {Array} 원본에 연결할 파생 이미지 사용처 목록 + */ +const getOriginalDerivativeMediaUsage = ( + originalUrl, + ownerByDerivativeUrl, + posts, + pages, + siteSettings, + tags, + options +) => getOwnedDerivativeUrls(originalUrl, ownerByDerivativeUrl) + .flatMap((derivativeUrl) => getDirectMediaUsage(derivativeUrl, posts, pages, siteSettings, tags) + .filter((usage) => !options.onlyLocation || usage.location === options.onlyLocation) + .map((usage) => ({ + ...usage, + location: options.location, + label: options.label, + sourceUrl: originalUrl, + derivativeUrl + }))) + +/** + * 원본 미디어 URL의 직접·파생 사용처를 함께 조회한다. + * @param {string} url - 미디어 URL + * @param {Array} posts - 게시물 목록 + * @param {Array} pages - 페이지 목록 + * @param {Object} siteSettings - 사이트 설정 + * @param {Array} tags - 태그 목록 + * @param {{ thumbnailOwnerByUrl: Map, pageCoverThumbnailOwnerByUrl: Map, bodyThumbnailOwnerByUrl: Map }} ownerMaps - 파생 URL별 원본 URL 맵 묶음 + * @returns {Array} 사용처 목록 + */ +const getOriginalMediaUsage = (url, posts, pages, siteSettings, tags, ownerMaps) => [ + ...getDirectMediaUsage(url, posts, pages, siteSettings, tags), + ...getOriginalDerivativeMediaUsage(url, ownerMaps.thumbnailOwnerByUrl, posts, pages, siteSettings, tags, { + location: 'featuredImageThumbnail', + label: '목록 카드 썸네일', + onlyLocation: 'featuredImage' + }), + ...getOriginalDerivativeMediaUsage(url, ownerMaps.pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings, tags, { + location: 'pageCoverThumbnail', + label: '페이지 커버 썸네일', + onlyLocation: 'featuredImage' + }), + ...getOriginalDerivativeMediaUsage(url, ownerMaps.bodyThumbnailOwnerByUrl, posts, pages, siteSettings, tags, { + location: 'contentBodyImage', + label: '본문 표시 이미지', + onlyLocation: 'content' + }) +] + +/** + * 원본 삭제 시 함께 제거할 자동 생성 파생 URL 목록을 반환한다. + * @param {string} originalUrl - 원본 미디어 URL + * @param {{ thumbnailOwnerByUrl: Map, pageCoverThumbnailOwnerByUrl: Map, bodyThumbnailOwnerByUrl: Map }} ownerMaps - 파생 URL별 원본 URL 맵 묶음 + * @returns {Array} 함께 제거할 파생 URL 목록 + */ +const getOwnedGeneratedMediaUrls = (originalUrl, ownerMaps) => [...new Set([ + ...getOwnedDerivativeUrls(originalUrl, ownerMaps.thumbnailOwnerByUrl), + ...getOwnedDerivativeUrls(originalUrl, ownerMaps.pageCoverThumbnailOwnerByUrl), + ...getOwnedDerivativeUrls(originalUrl, ownerMaps.bodyThumbnailOwnerByUrl) +])] + /** * 게시물 업로드 이미지의 카드 썸네일 상태를 조회한다. * @param {Object} item - 미디어 항목 @@ -885,11 +966,16 @@ export const listMediaItems = async () => { const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items) const pageCoverThumbnailOwnerByUrl = buildPageCoverThumbnailOwnerMap(items) const bodyThumbnailOwnerByUrl = buildPostBodyThumbnailOwnerMap(items) + const ownerMaps = { + thumbnailOwnerByUrl, + pageCoverThumbnailOwnerByUrl, + bodyThumbnailOwnerByUrl + } const itemsWithUsage = items.map((item) => { const rawCategory = metadataMap[item.url]?.category ?? item.category const category = normalizeStoredDisplayCategory(item.url, rawCategory) const avatarOwner = avatarOwnerByUrl.get(item.url) || null - const directUsage = getDirectMediaUsage(item.url, posts, pages, siteSettings, tags) + const directUsage = getOriginalMediaUsage(item.url, posts, pages, siteSettings, tags, ownerMaps) const thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings) const pageCoverThumbnailUsage = getPageCoverThumbnailMediaUsage(item.url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings) const bodyThumbnailUsage = getPostBodyThumbnailMediaUsage(item.url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings) @@ -1046,11 +1132,19 @@ export const deleteMediaItem = async (url) => { listAdminTags() ]) const items = await readMediaDirectory(uploadRoot) + const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items) + const pageCoverThumbnailOwnerByUrl = buildPageCoverThumbnailOwnerMap(items) + const bodyThumbnailOwnerByUrl = buildPostBodyThumbnailOwnerMap(items) + const ownerMaps = { + thumbnailOwnerByUrl, + pageCoverThumbnailOwnerByUrl, + bodyThumbnailOwnerByUrl + } const usage = [ - ...getDirectMediaUsage(url, posts, pages, siteSettings, tags), - ...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings), - ...getPageCoverThumbnailMediaUsage(url, buildPageCoverThumbnailOwnerMap(items), posts, pages, siteSettings), - ...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings) + ...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps), + ...getPostThumbnailMediaUsage(url, thumbnailOwnerByUrl, posts, pages, siteSettings), + ...getPageCoverThumbnailMediaUsage(url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings), + ...getPostBodyThumbnailMediaUsage(url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings) ] if (usage.length) { @@ -1067,8 +1161,15 @@ export const deleteMediaItem = async (url) => { }) } + const ownedGeneratedUrls = getOwnedGeneratedMediaUrls(url, ownerMaps) + await rm(resolveMediaPath(url)) await deleteMediaMetadata(url) + + for (const generatedUrl of ownedGeneratedUrls) { + await rm(resolveMediaPath(generatedUrl), { force: true }) + await deleteMediaMetadata(generatedUrl) + } } /** @@ -1092,11 +1193,19 @@ export const renameMediaItem = async (url, name) => { listAdminTags() ]) const items = await readMediaDirectory(uploadRoot) + const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items) + const pageCoverThumbnailOwnerByUrl = buildPageCoverThumbnailOwnerMap(items) + const bodyThumbnailOwnerByUrl = buildPostBodyThumbnailOwnerMap(items) + const ownerMaps = { + thumbnailOwnerByUrl, + pageCoverThumbnailOwnerByUrl, + bodyThumbnailOwnerByUrl + } const usage = [ - ...getDirectMediaUsage(url, posts, pages, siteSettings, tags), - ...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings), - ...getPageCoverThumbnailMediaUsage(url, buildPageCoverThumbnailOwnerMap(items), posts, pages, siteSettings), - ...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings) + ...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps), + ...getPostThumbnailMediaUsage(url, thumbnailOwnerByUrl, posts, pages, siteSettings), + ...getPageCoverThumbnailMediaUsage(url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings), + ...getPostBodyThumbnailMediaUsage(url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings) ] if (usage.length) {