From c686b1c8482fb49c6c75a8bc0cead53bf68d0b4a Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 1 Jul 2026 16:19:45 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=B5=9C=EC=A0=81=ED=99=94=C2=B7=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=B0=95=EC=8A=A4=C2=B7=EB=B2=88=ED=98=B8=ED=98=95=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20(v1.10.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../content/ContentMarkdownRenderer.vue | 87 +++++++++- components/content/ProseImage.vue | 52 +++++- components/site/SitePagination.vue | 145 ++++++++++++++++ docs/changelog.md | 17 ++ docs/deploy.md | 4 +- docs/history.md | 12 ++ docs/map.md | 15 +- docs/spec.md | 12 +- docs/todo.md | 15 ++ docs/update.md | 20 +++ lib/post-card-thumbnail-url.js | 40 +++++ package-lock.json | 4 +- package.json | 2 +- pages/admin/media/index.vue | 162 ++++++++++++++++-- pages/posts/index.vue | 42 +---- pages/tag/[slug].vue | 42 +---- server/repositories/content-repository.js | 11 +- .../routes/admin/api/media/thumbnail.post.js | 16 +- server/utils/media-library.js | 146 +++++++++++++--- server/utils/post-thumbnail-image.js | 111 +++++++++++- 20 files changed, 812 insertions(+), 143 deletions(-) create mode 100644 components/site/SitePagination.vue diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 1b1802e..8e1d4e4 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -5,6 +5,7 @@ import { isImageUrl, parseImageMarkdownLine } from '../../lib/markdown-image.js' +import { getPostBodyThumbnailUrl } from '../../lib/post-card-thumbnail-url.js' import { readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js' import { applyLiveSelectionDelete, @@ -989,6 +990,42 @@ const tailInsertBeforeLine = computed(() => { }) const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value]) +const contentLightboxImages = computed(() => { + const images = [] + + blocks.value.forEach((block) => { + if (block.type === 'image' && block.url) { + images.push({ + ...block, + lightboxKey: block.id + }) + return + } + + if (block.type === 'gallery' && Array.isArray(block.images)) { + block.images.forEach((image, imageIndex) => { + if (!image?.url) { + return + } + + images.push({ + ...image, + lightboxKey: `${block.id}:${imageIndex}` + }) + }) + } + }) + + return images +}) + +/** + * 게시물 본문 표시용 이미지 URL을 조회한다. + * @param {string|null|undefined} imageUrl - 원본 이미지 URL + * @returns {string} 표시용 이미지 URL + */ +const getContentDisplayImageUrl = (imageUrl) => getPostBodyThumbnailUrl(imageUrl) || imageUrl || '' + /** * 갤러리 이미지를 행 단위로 최대 3개씩 나눈다. * @param {Array} images - 갤러리 이미지 목록 @@ -1037,6 +1074,22 @@ const onGalleryImageLoad = (event, blockId, imageIndex, url) => { } } +/** + * 갤러리 표시용 이미지 로드 실패 시 원본 이미지로 되돌린다. + * @param {Event} event - 이미지 로드 이벤트 + * @param {string} fallbackUrl - 원본 이미지 URL + * @returns {void} + */ +const onGalleryImageError = (event, fallbackUrl) => { + const image = event.target + + if (!(image instanceof HTMLImageElement) || !fallbackUrl || image.src.endsWith(fallbackUrl)) { + return + } + + image.src = fallbackUrl +} + /** * 갤러리 이미지 셀의 flex 비율 스타일을 만든다. * @param {Object} block - 갤러리 블록 @@ -2457,6 +2510,22 @@ const openLightbox = (images, index) => { activeLightboxIndex.value = index } +/** + * 본문 전체 이미지 목록에서 지정 이미지를 라이트박스로 연다. + * @param {string} lightboxKey - 이미지 식별 키 + * @returns {void} + */ +const openContentLightbox = (lightboxKey) => { + const images = contentLightboxImages.value + const index = images.findIndex((image) => image.lightboxKey === lightboxKey) + + if (index < 0) { + return + } + + openLightbox(images, index) +} + /** * 라이트박스를 닫는다 * @returns {void} @@ -3131,18 +3200,24 @@ onBeforeUnmount(() => { {
String(props.src || '').trim().length > 0) +const loadFailed = ref(false) +const currentSrc = ref(props.src) + +const hasRenderableSrc = computed(() => String(currentSrc.value || '').trim().length > 0) const errorLabel = computed(() => { - const trimmed = String(props.src || '').trim() + const trimmed = String(props.fallbackSrc || props.src || '').trim() if (!trimmed) { return '이미지 URL이 비어 있습니다' @@ -38,6 +49,7 @@ const errorLabel = computed(() => { }) watch(() => props.src, () => { + currentSrc.value = props.src loadFailed.value = false }) @@ -46,6 +58,11 @@ watch(() => props.src, () => { * @returns {void} */ const onImageError = () => { + if (props.fallbackSrc && currentSrc.value !== props.fallbackSrc) { + currentSrc.value = props.fallbackSrc + return + } + loadFailed.value = true } @@ -56,6 +73,18 @@ const onImageError = () => { const onImageLoad = () => { loadFailed.value = false } + +/** + * 이미지 확대 요청을 전달한다. + * @returns {void} + */ +const activateImage = () => { + if (!props.expandable || !hasRenderableSrc.value || loadFailed.value) { + return + } + + emit('activate') +} +
+
본문 이미지
+
+ + 자동 생성된 본문 표시용 WebP 이미지입니다. + + + + 원본: {{ selectedMedia.bodyThumbnailStatus.originalUrl }} + + +
+
diff --git a/pages/posts/index.vue b/pages/posts/index.vue index 1bca364..d04db0d 100644 --- a/pages/posts/index.vue +++ b/pages/posts/index.vue @@ -95,15 +95,6 @@ const postCards = computed(() => (postsResponse.value?.items || []).map((post) = } })) -/** - * 게시물 목록 페이지 링크를 만든다. - * @param {number} page - 페이지 번호 - * @returns {Object} Nuxt 링크 대상 - */ -const getPostsPageTo = (page) => ({ - path: '/posts', - query: page > 1 ? { page } : {} -}) diff --git a/pages/tag/[slug].vue b/pages/tag/[slug].vue index c29666c..434a8ea 100644 --- a/pages/tag/[slug].vue +++ b/pages/tag/[slug].vue @@ -89,15 +89,6 @@ const tagPosts = computed(() => (postsResponse.value?.items || []) } })) -/** - * 태그 게시물 목록 페이지 링크를 만든다. - * @param {number} page - 페이지 번호 - * @returns {Object} Nuxt 링크 대상 - */ -const getTagPageTo = (page) => ({ - path: `/tag/${slug.value}`, - query: page > 1 ? { page } : {} -})