본문 이미지 최적화·라이트박스·번호형 페이지네이션 반영 (v1.10.3)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<Object>} 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(() => {
|
||||
</button>
|
||||
</div>
|
||||
<ProseImage
|
||||
:src="block.url"
|
||||
:src="getContentDisplayImageUrl(block.url)"
|
||||
:fallback-src="block.url"
|
||||
:alt="getImageAltAttribute(block)"
|
||||
:caption="getImageDisplayCaption(block)"
|
||||
:variant="block.width"
|
||||
expandable
|
||||
@activate="openContentLightbox(block.id)"
|
||||
/>
|
||||
</section>
|
||||
<ProseImage
|
||||
v-else-if="block.type === 'image'"
|
||||
:src="block.url"
|
||||
:src="getContentDisplayImageUrl(block.url)"
|
||||
:fallback-src="block.url"
|
||||
:alt="getImageAltAttribute(block)"
|
||||
:caption="getImageDisplayCaption(block)"
|
||||
:variant="block.width"
|
||||
expandable
|
||||
@activate="openContentLightbox(block.id)"
|
||||
/>
|
||||
<ContentMarkdownCalloutEditor
|
||||
v-else-if="block.type === 'callout' && interactive"
|
||||
@@ -3334,13 +3409,17 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
class="content-markdown-renderer__gallery-button w-full overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
|
||||
type="button"
|
||||
@click="openLightbox(block.images, row.startIndex + rowImageIndex)"
|
||||
aria-label="이미지 크게 보기"
|
||||
@click="openContentLightbox(`${block.id}:${row.startIndex + rowImageIndex}`)"
|
||||
>
|
||||
<img
|
||||
class="content-markdown-renderer__gallery-image h-full w-full object-cover transition-transform hover:scale-[1.02]"
|
||||
:src="image.url"
|
||||
:src="getContentDisplayImageUrl(image.url)"
|
||||
:alt="getImageAltAttribute(image)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load="onGalleryImageLoad($event, block.id, row.startIndex + rowImageIndex, image.url)"
|
||||
@error="onGalleryImageError($event, image.url)"
|
||||
>
|
||||
</button>
|
||||
<figcaption
|
||||
|
||||
@@ -6,6 +6,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
fallbackSrc: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
@@ -18,15 +22,22 @@ const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'regular'
|
||||
},
|
||||
expandable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const loadFailed = ref(false)
|
||||
const emit = defineEmits(['activate'])
|
||||
|
||||
const hasRenderableSrc = computed(() => 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')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,18 +95,25 @@ const onImageLoad = () => {
|
||||
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="prose-image__frame overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
|
||||
<component
|
||||
:is="expandable && hasRenderableSrc && !loadFailed ? 'button' : 'div'"
|
||||
class="prose-image__frame block w-full overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-0 text-left text-inherit"
|
||||
:class="{
|
||||
'prose-image__frame--empty': !hasRenderableSrc || loadFailed,
|
||||
'prose-image__frame--broken': loadFailed
|
||||
'prose-image__frame--broken': loadFailed,
|
||||
'prose-image__frame--expandable cursor-zoom-in': expandable && hasRenderableSrc && !loadFailed
|
||||
}"
|
||||
:type="expandable && hasRenderableSrc && !loadFailed ? 'button' : undefined"
|
||||
:aria-label="expandable && hasRenderableSrc && !loadFailed ? '이미지 크게 보기' : undefined"
|
||||
@click="activateImage"
|
||||
>
|
||||
<img
|
||||
v-if="hasRenderableSrc && !loadFailed"
|
||||
class="prose-image__media w-full object-cover"
|
||||
:src="src"
|
||||
:src="currentSrc"
|
||||
:alt="alt"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
>
|
||||
@@ -92,7 +128,7 @@ const onImageLoad = () => {
|
||||
{{ errorLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
<figcaption
|
||||
v-if="caption"
|
||||
class="prose-image__caption mt-1.5 text-center text-sm text-[var(--site-muted)]"
|
||||
|
||||
145
components/site/SitePagination.vue
Normal file
145
components/site/SitePagination.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '페이지'
|
||||
},
|
||||
basePath: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const normalizedTotalPages = computed(() => Math.max(1, Number(props.totalPages) || 1))
|
||||
const currentPage = computed(() => Math.max(1, Math.min(Number(props.page) || 1, normalizedTotalPages.value)))
|
||||
const previousPage = computed(() => Math.max(1, currentPage.value - 1))
|
||||
const nextPage = computed(() => Math.min(normalizedTotalPages.value, currentPage.value + 1))
|
||||
const canGoPrevious = computed(() => currentPage.value > 1)
|
||||
const canGoNext = computed(() => currentPage.value < normalizedTotalPages.value)
|
||||
|
||||
/**
|
||||
* 연속 페이지 번호를 만든다.
|
||||
* @param {number} start - 시작 페이지
|
||||
* @param {number} end - 끝 페이지
|
||||
* @returns {Array<number>} 페이지 번호 목록
|
||||
*/
|
||||
const range = (start, end) => {
|
||||
const pages = []
|
||||
|
||||
for (let page = start; page <= end; page += 1) {
|
||||
pages.push(page)
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
const pageItems = computed(() => {
|
||||
const total = normalizedTotalPages.value
|
||||
const current = currentPage.value
|
||||
|
||||
if (total <= 8) {
|
||||
return range(1, total).map((page) => ({ type: 'page', page }))
|
||||
}
|
||||
|
||||
const pages = new Set([1, total])
|
||||
|
||||
if (current <= 5) {
|
||||
range(1, Math.min(total - 1, Math.max(4, current + 3))).forEach((page) => pages.add(page))
|
||||
} else if (current >= total - 4) {
|
||||
range(Math.max(2, total - 7), total).forEach((page) => pages.add(page))
|
||||
} else {
|
||||
range(Math.max(2, current - 3), Math.min(total - 1, current + 3)).forEach((page) => pages.add(page))
|
||||
}
|
||||
|
||||
const sortedPages = [...pages].sort((left, right) => left - right)
|
||||
const items = []
|
||||
|
||||
sortedPages.forEach((page, index) => {
|
||||
const previous = sortedPages[index - 1]
|
||||
|
||||
if (previous && page - previous > 1) {
|
||||
items.push({
|
||||
type: 'ellipsis',
|
||||
key: `ellipsis-${previous}-${page}`
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: 'page',
|
||||
page
|
||||
})
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
/**
|
||||
* 페이지 링크 대상을 만든다.
|
||||
* @param {number} page - 페이지 번호
|
||||
* @returns {Object} Nuxt 링크 대상
|
||||
*/
|
||||
const getPageLinkTo = (page) => ({
|
||||
path: props.basePath,
|
||||
query: page > 1 ? { page } : {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="site-pagination site-section" :aria-label="label">
|
||||
<div class="site-pagination__body site-section-body mx-auto flex max-w-[720px] items-center justify-start gap-3 overflow-x-auto text-sm sm:justify-center sm:gap-4">
|
||||
<NuxtLink
|
||||
class="site-pagination__arrow inline-flex size-10 items-center justify-center rounded-full border border-[var(--site-line)] text-[var(--site-muted)] transition hover:border-[var(--site-soft)] hover:text-[var(--site-text)]"
|
||||
:class="!canGoPrevious ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getPageLinkTo(previousPage)"
|
||||
:aria-disabled="!canGoPrevious"
|
||||
:aria-label="`${label} 이전으로 이동`"
|
||||
>
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
|
||||
<ol class="site-pagination__pages flex min-w-max items-center justify-center gap-1 sm:gap-2">
|
||||
<li v-for="item in pageItems" :key="item.type === 'page' ? `page-${item.page}` : item.key">
|
||||
<span
|
||||
v-if="item.type === 'ellipsis'"
|
||||
class="site-pagination__ellipsis inline-flex min-w-9 items-center justify-center px-1 text-base font-semibold text-[var(--site-muted)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-else
|
||||
class="site-pagination__page inline-flex min-w-9 items-center justify-center rounded-full px-2 py-2 text-base font-semibold text-[var(--site-muted)] transition hover:text-[var(--site-text)]"
|
||||
:class="item.page === currentPage ? 'site-pagination__page--current pointer-events-none text-[var(--site-text)]' : ''"
|
||||
:to="getPageLinkTo(item.page)"
|
||||
:aria-current="item.page === currentPage ? 'page' : undefined"
|
||||
:aria-label="`${item.page}페이지로 이동`"
|
||||
>
|
||||
{{ item.page }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<NuxtLink
|
||||
class="site-pagination__arrow inline-flex size-10 items-center justify-center rounded-full border border-[var(--site-line)] text-[var(--site-muted)] transition hover:border-[var(--site-soft)] hover:text-[var(--site-text)]"
|
||||
:class="!canGoNext ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getPageLinkTo(nextPage)"
|
||||
:aria-disabled="!canGoNext"
|
||||
:aria-label="`${label} 다음으로 이동`"
|
||||
>
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,5 +1,22 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.10.3
|
||||
|
||||
- 게시물 목록과 태그 목록 하단 페이지네이션을 번호형 빠른 이동 UI로 바꿨다.
|
||||
- 페이지가 많으면 처음·끝과 현재 페이지 주변 번호만 보여 주고 중간은 `...`으로 접는다.
|
||||
|
||||
## v1.10.2
|
||||
|
||||
- 게시물 본문 이미지도 자동으로 본문 표시용 WebP 이미지를 생성해 우선 표시한다.
|
||||
- 관리자 미디어에 본문 이미지 탭을 추가해 원본·본문 표시용 이미지·카드 썸네일을 따로 관리할 수 있게 했다.
|
||||
- 미디어 라이브러리에서 중복으로 보이던 `전체` 필터 배지를 정리했다.
|
||||
|
||||
## v1.10.1
|
||||
|
||||
- 게시물 본문 일반 이미지도 클릭해서 크게 볼 수 있게 했다.
|
||||
- 한 게시물 안의 일반 이미지와 갤러리 이미지를 좌우 방향키로 이어서 볼 수 있게 했다.
|
||||
- 이미지 최적화와 업로드 총량 제한 등 다음 개선 과제를 정리했다.
|
||||
|
||||
## v1.10.0
|
||||
|
||||
- 글쓰기에서 Post URL 입력 직후 첫 자동 저장 중 태그나 본문을 추가하면 저장 완료 후 입력값이 사라질 수 있던 문제를 수정했다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.10.0에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.10.1에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -405,6 +405,8 @@ docker compose --env-file .env.production restart sori-studio-db
|
||||
| `npm run images:backfill-post-thumbnails` | 게시물 대표 이미지용 카드 썸네일(`thumbs/*-card.webp`)이 누락된 기존 업로드를 일괄 생성 |
|
||||
| `npm run navigation:refresh-recommended-favicons` | 추천 사이트 파비콘 캐시를 수동으로 전체 갱신 |
|
||||
|
||||
본문 표시용 WebP 이미지(`thumbs/*-body.webp`)는 게시물 저장 시 생성된다. 기존 게시물의 과거 이미지는 다시 저장하거나 관리자 미디어 상세에서 개별 생성한다.
|
||||
|
||||
Homepage 위젯 연동은 `docs/homepage-widget.md`를 참고한다. Docker에서 Homepage가 같은 네트워크에 있으면 `http://sori-studio:3000/api/homepage-widget`을 사용한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-07-01 v1.10.3 — 공개 목록 페이지네이션은 직접 이동을 기본으로 한다
|
||||
|
||||
이전/다음만 있는 페이지네이션은 글이 수십 개씩 추가되는 기간에 원하는 구간으로 이동하기 어렵다. 공개 게시물 목록과 태그 목록은 서버 페이지네이션을 이미 사용하므로, UI는 처음·끝 페이지와 현재 위치 주변 번호를 보여 주고 중간 구간은 `...`으로 접는 방식으로 바꾼다. 모바일에서는 번호가 많을 때 한 줄 가로 스크롤을 허용해 레이아웃 깨짐을 피한다.
|
||||
|
||||
## 2026-07-01 v1.10.2 — 본문 이미지는 원본과 표시용 파일을 분리한다
|
||||
|
||||
게시물 본문에 4K·고용량 이미지가 여러 장 들어가면 공개 화면 초기 로딩 비용이 커진다. 원본은 라이트박스와 보관용으로 유지하되, 본문 표시에는 `thumbs/*-body.webp` 파생 이미지를 우선 사용해 데이터 사용량을 줄인다. 관리자 미디어에서는 원본, 본문 이미지, 카드 썸네일을 탭으로 분리해 자동 생성 파일의 사용처와 삭제 제한을 쉽게 확인하게 한다.
|
||||
|
||||
## 2026-07-01 v1.10.1 — 게시물 이미지는 하나의 감상 흐름으로 묶는다
|
||||
|
||||
게시물 안에서 단일 이미지와 갤러리는 작성 구조상 다른 블록이지만, 독자는 이미지를 빠르게 이어 보는 경험을 기대한다. 따라서 라이트박스는 갤러리 단위가 아니라 게시물 본문 전체 이미지 단위로 구성하고, 클릭한 이미지 위치부터 문서 순서대로 이전·다음 탐색을 제공한다. 갤러리는 배치 표현만 담당하고, 감상 흐름은 게시물 전체 이미지 그룹이 담당한다.
|
||||
|
||||
## 2026-07-01 v1.10.0 — 저장 완료 기준은 요청 시점 payload로 고정한다
|
||||
|
||||
게시물 저장 요청이 진행되는 동안 사용자가 태그나 본문을 계속 수정할 수 있다. 성공 응답 시점의 화면 값을 그대로 저장 완료 기준으로 삼으면 실제 서버에 저장된 payload보다 최신 입력까지 저장된 것으로 오판하고, 신규 글의 첫 자동 저장 후 편집 URL로 이동하면서 최신 입력이 사라질 수 있다. 따라서 저장 완료 스냅샷은 요청에 보낸 payload로 고정하고, 저장 중 생긴 추가 변경은 저장이 끝난 뒤 다시 자동 저장되게 한다. 발행 저장 중에는 미저장 변경으로 간주해 라우트 이탈 확인을 유지한다.
|
||||
|
||||
15
docs/map.md
15
docs/map.md
@@ -26,7 +26,7 @@
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·용량 문구(서버·클라이언트 공용) |
|
||||
| lib/post-card-thumbnail-url.js | 게시물 카드 썸네일 `thumbs/*-card.webp` URL 판별(대표 이미지·본문 이미지·갤러리 선택 및 서버 검증 공용) |
|
||||
| lib/post-card-thumbnail-url.js | 게시물 카드 썸네일 `thumbs/*-card.webp`와 본문 표시용 이미지 `thumbs/*-body.webp` URL 판별(대표 이미지·본문 이미지·갤러리 선택 및 서버 검증 공용) |
|
||||
| lib/external-favicon-url.js | 외부 URL origin 기준 Google `gstatic.com/faviconV2` 파비콘 URL 생성(내부 경로는 빈 문자열) |
|
||||
| lib/navigation-url.js | 네비게이션 URL 접두사 분리·합성·정규화(`https://` 자동 보정, `/` 내부 경로) |
|
||||
| lib/recommended-site-icon-url.js | 추천 사이트 아이콘 URL 우선순위(수동 썸네일 > 서버 캐시 > 외부 프록시) |
|
||||
@@ -91,7 +91,7 @@
|
||||
| server/routes/feed.xml.get.js | 공개 RSS 2.0 피드 별칭(`/feed.xml`) |
|
||||
| server/routes/rss.get.js | 공개 RSS 2.0 피드 별칭(`/rss`) |
|
||||
| server/plugins/site-custom-code.js | 공개 Nuxt HTML 응답에 사이트 설정 헤더·푸터 코드 삽입(`/admin`, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 제외) |
|
||||
| server/utils/post-thumbnail-image.js | 게시물 업로드 이미지의 카드 썸네일 URL·디스크 경로·생성 가능 여부 규칙 |
|
||||
| server/utils/post-thumbnail-image.js | 게시물 업로드 이미지의 카드 썸네일·본문 표시용 WebP 이미지 URL·디스크 경로·생성 가능 여부·본문 이미지 추출 규칙 |
|
||||
| server/utils/sitemap.js | 공개 sitemap XML·robots.txt 생성 |
|
||||
| server/utils/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 |
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 색상 배지 메타에 표시하고 태그 페이지 링크 제공, 오른쪽 아래 게시물 링크 화살표 |
|
||||
| components/site/PostCardMedia.vue | 게시물 카드 썸네일(`featuredImageThumbnail` 우선, 없으면 대표 이미지 원본 사용. 대표 이미지 없으면 모바일 3줄·`sm` 이상 4줄 제목 placeholder 표시, 이미지 유무와 관계없이 썸네일 영역 크기 고정) |
|
||||
| components/site/SitePagination.vue | 공개 목록 공용 번호형 페이지네이션, 이전·다음 화살표와 첫/끝/현재 주변 페이지 직접 이동, 많은 페이지의 `...` 접기, 모바일 가로 스크롤 |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 입력값 기반 등록 버튼 활성화, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||
|
||||
@@ -147,12 +148,12 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `<br>` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `<br>` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 본문 표시용 WebP 이미지 우선 표시와 원본 fallback, 단일 이미지·갤러리 통합 라이트박스, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 |
|
||||
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, 위/아래 인접 블록 이동 시 편집 스크롤 추적, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
|
||||
| components/content/ContentMarkdownInlineSegments.vue | 공개·라이브 본문 공통 인라인 세그먼트 렌더링(링크·굵게·기울임·인라인 코드·첨자·인라인 색상), 링크·색상 내부 중첩 인라인 렌더링, 사용자 화면 fragment 주석 반복을 줄이는 단일 루트 구조 |
|
||||
| lib/markdown-live-selection.js | 라이브 모드 Selection Bridge, 인접 contenteditable 범위 확장·블록/문서 전체 선택·교차 선택 삭제 마크다운 반영, 콜아웃·인용 전체 선택 삭제 시 빈 본문 줄 보존, Selection focus 기준 연속 확장 |
|
||||
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지, 클릭 확대 요청, 본문 표시용 이미지 실패 시 원본 fallback, lazy 로딩, 로드 실패·빈 URL placeholder |
|
||||
| components/content/ProseList.vue | 목록 |
|
||||
| components/content/ProseBlockquote.vue | 인용구, 왼쪽 세로 막대형 기본 스타일과 색상 프리셋, 다크모드 기본 인용 텍스트 가독성 보정 |
|
||||
| components/content/ProseCodeBlock.vue | 코드 블록 공통 셸(다크 배경, 선택 줄번호 gutter, 공개 복사 버튼) |
|
||||
@@ -186,7 +187,7 @@
|
||||
| pages/admin/pages/index.vue | 페이지 목록, 제목 아래 주소·공개 보기 hover 아이콘, 홈 표시·섹션·추천 배지, 조회수·통계 모달, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
|
||||
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 홈 Pages 진열장 노출 메타, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 홈 Pages 진열장 노출 메타, 설정 패널 삭제, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 모든 탭 공통 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, 현재 필터 결과의 삭제 가능 항목 전체 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·카드 썸네일 사용 배지(카드 썸네일 탭의 카드 배지는 숨김), 대표 이미지 원본 카드 썸네일 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/본문 이미지/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 라이브러리 파일 종류 필터와 미사용 토글, 시스템 탭 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, 현재 필터 결과의 삭제 가능 항목 전체 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·카드 썸네일·본문 이미지 사용 배지(파생 탭의 해당 배지는 숨김), 대표 이미지 원본 카드 썸네일 생성·재생성, 본문 이미지 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 메인 네비게이션/페이지 섹션/하단 네비게이션/추천 사이트 탭, 각 탭 작업줄 왼쪽 추가·오른쪽 저장 버튼, 메인·하단 메뉴 추가 모달, 페이지 섹션 추가 모달·수정·삭제·섹션 드래그 정렬·섹션별 카드형 페이지 순서 변경 모달(추천 우선 표시·섹션에서 제거 포함), 추천 사이트는 공개와 같은 카드 목록+편집 모달(전체 URL·썸네일·파비콘 갱신), 행 more vert(삭제), 저장 대상별 버튼(`네비게이션 저장`/`페이지 섹션 저장`), `useAdminToast` |
|
||||
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
|
||||
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
|
||||
@@ -204,12 +205,12 @@
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 라이트·다크 이미지 지원 `HomeHero`, Featured/Latest, 사이트 설정 표시개수 기준 Latest·Featured·Pages 데이터 제한, 홈 표시 페이지를 섹션별 도서 진열장형 Pages 선반으로 노출, Pages 추천 카드는 왼쪽 위 SVG 아이콘 배지 표시, Featured와 Pages 선반의 독립 이전·다음 버튼 및 문서 레벨 포인터 추적 기반 드래그 슬라이드·스냅(이동 임계값 미만 클릭은 링크 이동, 포인터 캡처는 드래그 확정 뒤 적용), Pages 선반 ResizeObserver 기반 버튼 상태 갱신, Pages 카드 hover 상단 여백 보정, Latest 피드 Compact 기본값·List·Cards 보기, Compact 모바일 썸네일 80px 정사각형·Compact/List 설명 2줄 제한, Latest 메타는 태그가 있을 때만 링크 배지 표시, 카드 오른쪽 아래 화살표는 게시물 상세 링크, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록, 사이트 설정 Posts 표시개수 기준 서버 페이지네이션, 태그는 있을 때만 카드 메타에 링크로 표시 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록, 사이트 설정 Posts 표시개수 기준 서버 페이지네이션, 공용 번호형 페이지네이션, 태그는 있을 때만 카드 메타에 링크로 표시 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, H1~H3 본문 제목을 오른쪽 TOC 상태로 제공, 공개 회원 로그인 세션의 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 사이트 설정 태그 게시물 표시개수 기준 서버 태그 필터·페이지네이션, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드, 카드 태그 배지와 오른쪽 아래 화살표 링크 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 사이트 설정 태그 게시물 표시개수 기준 서버 태그 필터·공용 번호형 페이지네이션, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드, 카드 태그 배지와 오른쪽 아래 화살표 링크 |
|
||||
| pages/pages/[slug].vue | 고정 페이지 상세, HTML 문서 모드는 직접 진입 시 서버 미들웨어 원문 응답·클라이언트 내부 이동 시 현재 URL reload로 서버 원문 응답 재진입 |
|
||||
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, 모바일 OTP 입력창 높이 보정, `POST /api/auth/email-otp/request` |
|
||||
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
|
||||
|
||||
12
docs/spec.md
12
docs/spec.md
@@ -134,13 +134,13 @@
|
||||
|
||||
### 공개 URL 구조
|
||||
|
||||
- `/posts` - 게시물 전체 목록. 사이트 설정 POST 설정의 `postListPageSize` 값 기준으로 서버 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다.
|
||||
- `/posts` - 게시물 전체 목록. 사이트 설정 POST 설정의 `postListPageSize` 값 기준으로 서버 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다. 하단 페이지네이션은 이전·다음 화살표와 페이지 번호 직접 이동을 함께 제공하며, 전체 페이지가 많으면 첫 페이지·끝 페이지·현재 페이지 주변 번호를 유지하고 중간 구간은 `...`으로 접는다.
|
||||
- `/post/:slug` - 개별 게시물 상세
|
||||
- `/sitemap.xml` - 공개 sitemap XML. 홈, 게시물 목록, 태그 목록, 공개 발행글, 공개 고정 페이지, 글이 있는 태그 URL을 요청 시점 DB 기준으로 자동 생성한다. 멤버십·비공개·초안·예약 대기 글과 `noindex` 글은 포함하지 않는다.
|
||||
- `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다.
|
||||
- `/rss.xml`, `/feed.xml`, `/rss` - 최근 공개 발행글 RSS 2.0 피드. 멤버십·비공개·초안·예약 대기 글은 포함하지 않으며, 최신순 최대 50개를 XML로 응답한다. 게시물에 대표 이미지 또는 OG 이미지가 있으면 절대 URL로 변환해 `media:thumbnail`과 `media:content medium="image"`를 함께 포함한다.
|
||||
- `/tags` - 태그 전체 목록
|
||||
- `/tag/:slug` - 태그별 게시물 목록. 사이트 설정 POST 설정의 `tagPostListPageSize` 값 기준으로 서버 태그 필터와 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다.
|
||||
- `/tag/:slug` - 태그별 게시물 목록. 사이트 설정 POST 설정의 `tagPostListPageSize` 값 기준으로 서버 태그 필터와 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다. 하단 페이지네이션은 게시물 전체 목록과 같은 번호형 빠른 이동 UI를 사용한다.
|
||||
- 태그명에 `/`를 입력하면 태그명은 그대로 저장하고 슬러그에서는 `/`를 `-`로 변환한다. 예: `Xenoblade3/Hero` 태그명은 `xenoblade3-hero` 슬러그로 저장한다.
|
||||
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
|
||||
- 태그에 `headerImageUrl`이 있으면 `/tag/:slug` 상단에는 기본 태그명·설명 대신 태그 헤더 이미지를 표시한다. `headerTitle`, `headerText`는 이미지 위 오버레이로 표시하며, 둘 다 비어 있으면 이미지 단독으로 표시한다.
|
||||
@@ -253,10 +253,10 @@ components/content/
|
||||
- 캡션(표시용): `` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시
|
||||
- **파일명을 캡션으로 사용** 토글: URL 파일명을 캡션으로 저장·표시(``). 레거시 ``도 동일하게 해석
|
||||
- 와이드/풀: `{width=wide|full}` 또는 캡션·width 조합
|
||||
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경)
|
||||
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경). 클릭 시 라이트박스로 확대하며, 같은 게시물 안의 단일 이미지와 갤러리 이미지를 문서 순서대로 하나의 그룹으로 묶어 Esc 닫기·←/→ 이전·다음 탐색을 지원한다.
|
||||
- 이미지 갤러리
|
||||
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
||||
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 라이트박스, Esc 닫기·←/→ 이전·다음)
|
||||
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 게시물 전체 이미지 라이트박스, Esc 닫기·←/→ 이전·다음)
|
||||
- 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다.
|
||||
- 비디오·오디오·파일 카드
|
||||
- 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄)
|
||||
@@ -768,7 +768,7 @@ components/content/
|
||||
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `` 또는 파일명 캡션 토글 시 `` 형식으로 저장한다. 단독 이미지 파일 URL(`jpg`, `png`, `webp`, `gif`, `avif`, `svg`) 한 줄은 임베드가 아니라 이미지 블록으로 렌더링한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `` 또는 파일명 캡션 토글 시 `` 형식으로 저장한다. 단독 이미지 파일 URL(`jpg`, `png`, `webp`, `gif`, `avif`, `svg`) 한 줄은 임베드가 아니라 이미지 블록으로 렌더링한다. 게시물 저장 시 `/uploads/posts/YYYY/MM/` 아래의 본문 `jpg`·`png`·`webp` 이미지는 원본 옆 `thumbs/*-body.webp` 표시용 이미지로 자동 생성하며, 공개 본문 단일 이미지와 갤러리는 이 표시용 이미지를 우선 사용하고 실패하면 원본 URL로 대체한다. 라이트박스는 원본 이미지 URL 목록을 기준으로 연다.
|
||||
- 이미지/갤러리 삽입 시 캡션은 기본 비우며, 블록 설정 패널에서 **파일명을 캡션으로 사용** 토글로 이미지 아래에 URL 파일명을 표시한다.
|
||||
- 라이브 모드 이미지 블록은 hover/focus 시 우측 상단에 `삭제` 버튼만 표시한다. 이미지 URL·캡션·파일명 캡션 사용 여부 수정은 해당 이미지 블록에 포커스했을 때 오른쪽 이미지 설정 패널에서 처리한다.
|
||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
@@ -912,7 +912,7 @@ components/content/
|
||||
- 관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록).
|
||||
- 업로드 파일 크기 제한은 종류별 환경 변수를 따른다. 이미지·아바타·로고 등은 `MAX_FILE_SIZE`(기본 10MB), 비디오는 `MAX_VIDEO_FILE_SIZE`(기본 200MB), 오디오는 `MAX_AUDIO_FILE_SIZE`(기본 50MB), 문서·ZIP 등은 `MAX_DOCUMENT_FILE_SIZE`(기본 50MB).
|
||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||
- 관리자 미디어 화면 상단에 **미디어 라이브러리**·**카드 썸네일**·**프로필 이미지** 탭을 두어, 라이브러리 탭에서는 원본 업로드 파일만, 카드 썸네일 탭에서는 `/posts/YYYY/MM/thumbs/*-card.webp` 파일만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. 모든 탭에서 전체/미사용 배지형 필터를 표시하고, 목록 카드 오른쪽 위에는 실제 사용 개수 숫자 배지를 표시한다. 카드 썸네일 탭에서는 모든 항목이 카드 파생 파일이므로 `카드` 배지는 표시하지 않는다.
|
||||
- 관리자 미디어 화면 상단에 **미디어 라이브러리**·**카드 썸네일**·**본문 이미지**·**프로필 이미지** 탭을 두어, 라이브러리 탭에서는 원본 업로드 파일만, 카드 썸네일 탭에서는 `/posts/YYYY/MM/thumbs/*-card.webp` 파일만, 본문 이미지 탭에서는 `/posts/YYYY/MM/thumbs/*-body.webp` 파일만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. 라이브러리 탭의 파일 종류 필터는 `전체/이미지/영상/음악/파일`을 표시하고 사용 상태 필터는 중복 `전체` 배지를 피하기 위해 `미사용` 토글만 표시한다. 시스템 파생 탭에서는 전체/미사용 배지형 필터를 표시하고, 목록 카드 오른쪽 위에는 실제 사용 개수 숫자 배지를 표시한다. 카드 썸네일 탭과 본문 이미지 탭에서는 모든 항목이 해당 파생 파일이므로 각각 `카드`, `본문` 배지는 표시하지 않는다.
|
||||
- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||
- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다.
|
||||
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다. 현재 필터 결과의 전체 선택은 사용 중이 아니어서 삭제 가능한 항목만 대상으로 한다.
|
||||
|
||||
15
docs/todo.md
15
docs/todo.md
@@ -8,6 +8,21 @@
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
- 공개 게시물 본문 이미지 최적화 후속 진행.
|
||||
- 현재 게시물 저장 시 본문 표시용 WebP(`thumbs/*-body.webp`, 1600px)가 생성되고 공개 본문에서 우선 표시됨.
|
||||
- 기존 업로드 이미지 전체에 대한 백필 스크립트가 아직 없으므로 과거 게시물은 다시 저장하거나 미디어 상세에서 개별 생성해야 함.
|
||||
- 화면 크기별 `picture`/`srcset`과 갤러리 미리보기용 더 작은 파생 이미지(예: 720px)는 아직 미구현.
|
||||
- 라이트박스는 현재 원본 URL을 사용하므로, 초고용량 원본이 많은 경우 큰 파생 이미지와 원본 보기 옵션 분리 검토.
|
||||
- 업로드 총량 제한 및 메모리 사용량 개선.
|
||||
- 현재 관리자 업로드는 파일별 제한만 있고 한 번에 올리는 파일 개수·총 용량 제한이 없음.
|
||||
- 서버는 multipart 전체를 메모리에 받은 뒤 검사하므로, 고용량 다중 업로드 시 메모리 피크가 생길 수 있음.
|
||||
- 클라이언트·서버 양쪽에 총 파일 수/총 바이트 제한을 추가하고, 장기적으로 스트리밍 업로드 파서 도입 검토.
|
||||
- 업로드 파일 캐시 정책 개선.
|
||||
- 현재 `/uploads/**` 응답은 `cache-control: no-cache`라 이미지가 많은 페이지에서 재검증 요청이 늘어날 수 있음.
|
||||
- 업로드 파일명은 충돌 방지로 고유하게 저장되므로 `public, max-age=31536000, immutable` 적용 가능 여부 검토.
|
||||
- 공개 통합 검색 인덱스 개선.
|
||||
- 현재 검색은 게시물·페이지 `content`에 `lower(content)` 부분 일치를 수행함.
|
||||
- 게시물이 수백~수천 개로 늘면 검색 모달 입력마다 DB 스캔 비용이 커질 수 있으므로 PostgreSQL full-text search, `pg_trgm`, 검색용 정규화 컬럼 중 하나로 전환 검토.
|
||||
|
||||
## 콘텐츠 스타일 구현
|
||||
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.10.3
|
||||
|
||||
- 공개 게시물 목록과 태그 게시물 목록 페이지네이션을 번호형 빠른 이동 UI로 교체.
|
||||
- 페이지 수가 많을 때 처음·끝 페이지와 현재 페이지 주변 번호를 유지하고 중간 구간을 `...`으로 접도록 정리.
|
||||
- 좁은 화면에서 페이지 번호가 넘칠 때 가로 스크롤로 탐색할 수 있도록 보정.
|
||||
|
||||
## v1.10.2
|
||||
|
||||
- 게시물 저장 시 본문 이미지의 WebP 표시용 파생 파일(`thumbs/*-body.webp`) 생성 추가.
|
||||
- 공개 본문 단일 이미지·갤러리 표시에서 본문 표시용 이미지를 우선 사용하고 실패 시 원본으로 대체하도록 수정.
|
||||
- 관리자 미디어에 `본문 이미지` 탭 추가 및 원본 상세의 본문 이미지 생성·재생성 액션 추가.
|
||||
- 관리자 미디어 라이브러리의 중복 `전체` 필터 배지 제거 및 `미사용` 토글 동작 정리.
|
||||
|
||||
## v1.10.1
|
||||
|
||||
- 공개 본문 단일 이미지를 클릭하면 라이트박스로 크게 볼 수 있도록 수정.
|
||||
- 게시물 안의 단일 이미지와 갤러리 이미지를 하나의 라이트박스 그룹으로 묶어 이전·다음 탐색이 이어지도록 정리.
|
||||
- 본문 단일 이미지와 갤러리 이미지에 lazy 로딩·비동기 디코딩 속성 추가.
|
||||
- 이미지 최적화·업로드 총량 제한·업로드 캐시·검색 인덱스 개선 과제를 `docs/todo.md`에 기록.
|
||||
|
||||
## v1.10.0
|
||||
|
||||
- 관리자 글쓰기 자동 저장 중 추가 입력한 본문·태그가 저장 완료 처리와 화면 이동으로 사라지지 않도록 수정.
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
const postCardThumbnailUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-card\.webp$/i
|
||||
const postBodyThumbnailUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-body\.webp$/i
|
||||
const postUploadImageUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^/?#]+\.(?:jpe?g|png|webp)$/i
|
||||
|
||||
/**
|
||||
* 게시물 원본 이미지 URL에 대응하는 파생 이미지 URL을 만든다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
* @param {'body'} kind - 파생 이미지 종류
|
||||
* @returns {string} 파생 이미지 URL
|
||||
*/
|
||||
const getPostDerivativeImageUrl = (imageUrl, kind) => {
|
||||
const normalizedUrl = String(imageUrl || '')
|
||||
|
||||
if (!postUploadImageUrlPattern.test(normalizedUrl)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const slashIndex = normalizedUrl.lastIndexOf('/')
|
||||
const directory = slashIndex >= 0 ? normalizedUrl.slice(0, slashIndex) : ''
|
||||
const fileName = slashIndex >= 0 ? normalizedUrl.slice(slashIndex + 1) : normalizedUrl
|
||||
const dotIndex = fileName.lastIndexOf('.')
|
||||
const stem = dotIndex >= 0 ? fileName.slice(0, dotIndex) : fileName
|
||||
|
||||
return `${directory}/thumbs/${stem}-${kind}.webp`
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 카드 썸네일 URL인지 확인한다.
|
||||
@@ -8,3 +32,19 @@ const postCardThumbnailUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^
|
||||
export const isPostCardThumbnailUrl = (imageUrl) => Boolean(
|
||||
imageUrl && postCardThumbnailUrlPattern.test(String(imageUrl))
|
||||
)
|
||||
|
||||
/**
|
||||
* 게시물 본문 표시용 파생 이미지 URL인지 확인한다.
|
||||
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
|
||||
* @returns {boolean} 본문 표시용 파생 이미지 여부
|
||||
*/
|
||||
export const isPostBodyThumbnailUrl = (imageUrl) => Boolean(
|
||||
imageUrl && postBodyThumbnailUrlPattern.test(String(imageUrl))
|
||||
)
|
||||
|
||||
/**
|
||||
* 게시물 원본 이미지 URL에 대응하는 본문 표시용 이미지 URL을 만든다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
* @returns {string} 본문 표시용 파생 이미지 URL
|
||||
*/
|
||||
export const getPostBodyThumbnailUrl = (imageUrl) => getPostDerivativeImageUrl(imageUrl, 'body')
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.3",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -34,6 +34,7 @@ const editingName = ref('')
|
||||
const editingCategory = ref('')
|
||||
const deletingUrl = ref('')
|
||||
const regeneratingThumbnailUrl = ref('')
|
||||
const regeneratingBodyThumbnailUrl = ref('')
|
||||
const selectedMediaUrl = ref('')
|
||||
const selectedMediaUrls = ref([])
|
||||
const lastSelectedIndex = ref(-1)
|
||||
@@ -71,12 +72,19 @@ const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avat
|
||||
*/
|
||||
const isPostCardThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/posts/') && item?.url?.includes('/thumbs/') && item?.url?.endsWith('-card.webp'))
|
||||
|
||||
/**
|
||||
* 게시물 본문 표시용 이미지 디스크 경로 여부
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 게시물 본문 표시용 이미지이면 true
|
||||
*/
|
||||
const isPostBodyThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/posts/') && item?.url?.includes('/thumbs/') && item?.url?.endsWith('-body.webp'))
|
||||
|
||||
/**
|
||||
* 미디어 항목이 시스템 파생 파일인지 확인한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 시스템 관리 항목이면 true
|
||||
*/
|
||||
const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item)
|
||||
const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item) || isPostBodyThumbnailDiskItem(item)
|
||||
|
||||
/**
|
||||
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
|
||||
@@ -90,7 +98,7 @@ const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 변경 가능 여부
|
||||
*/
|
||||
const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item)
|
||||
const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)
|
||||
|
||||
/**
|
||||
* 미디어 논리 폴더를 변경할 수 있는지 확인한다.
|
||||
@@ -114,10 +122,22 @@ const getMediaBadgeLabel = (item) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (activeTab.value === 'postBodyThumbnails' && item?.bodyThumbnailStatus?.role === 'body') {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (item?.thumbnailStatus?.role === 'card') {
|
||||
return '카드'
|
||||
}
|
||||
|
||||
if (item?.bodyThumbnailStatus?.role === 'body') {
|
||||
return '본문'
|
||||
}
|
||||
|
||||
if (item?.bodyThumbnailStatus?.isFallbackActive) {
|
||||
return '원본'
|
||||
}
|
||||
|
||||
if (item?.thumbnailStatus?.isFallbackActive) {
|
||||
return '원본'
|
||||
}
|
||||
@@ -131,7 +151,7 @@ const getMediaBadgeLabel = (item) => {
|
||||
* @returns {string} Tailwind 클래스
|
||||
*/
|
||||
const getMediaBadgeClass = (item) => {
|
||||
if (item?.thumbnailStatus?.isFallbackActive) {
|
||||
if (item?.thumbnailStatus?.isFallbackActive || item?.bodyThumbnailStatus?.isFallbackActive) {
|
||||
return 'bg-amber-600 text-white'
|
||||
}
|
||||
|
||||
@@ -139,6 +159,10 @@ const getMediaBadgeClass = (item) => {
|
||||
return 'bg-sky-700 text-white'
|
||||
}
|
||||
|
||||
if (item?.bodyThumbnailStatus?.role === 'body') {
|
||||
return 'bg-violet-700 text-white'
|
||||
}
|
||||
|
||||
if (item?.avatarOwner) {
|
||||
return 'bg-emerald-800 text-white'
|
||||
}
|
||||
@@ -168,6 +192,16 @@ const canRegeneratePostThumbnail = (item) => (
|
||||
&& (item.thumbnailStatus.isFallbackActive || item.thumbnailStatus.hasThumbnail)
|
||||
)
|
||||
|
||||
/**
|
||||
* 본문 표시용 이미지를 생성할 수 있는 원본 이미지인지 확인한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 생성 가능 여부
|
||||
*/
|
||||
const canRegeneratePostBodyThumbnail = (item) => (
|
||||
item?.bodyThumbnailStatus?.role === 'original'
|
||||
&& (item.bodyThumbnailStatus.isFallbackActive || item.bodyThumbnailStatus.hasThumbnail)
|
||||
)
|
||||
|
||||
/**
|
||||
* 미디어 항목 종류를 반환한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
@@ -175,10 +209,12 @@ const canRegeneratePostThumbnail = (item) => (
|
||||
*/
|
||||
const getMediaItemKind = (item) => item?.kind || 'image'
|
||||
|
||||
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item) && !isPostCardThumbnailDiskItem(item)))
|
||||
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item) && !isPostCardThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)))
|
||||
|
||||
const postThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostCardThumbnailDiskItem(item)))
|
||||
|
||||
const postBodyThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostBodyThumbnailDiskItem(item)))
|
||||
|
||||
const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isThumbnailDiskItem(item)))
|
||||
|
||||
const scopeItems = computed(() => {
|
||||
@@ -190,22 +226,48 @@ const scopeItems = computed(() => {
|
||||
return postThumbnailMediaItems.value
|
||||
}
|
||||
|
||||
if (activeTab.value === 'postBodyThumbnails') {
|
||||
return postBodyThumbnailMediaItems.value
|
||||
}
|
||||
|
||||
return libraryMediaItems.value
|
||||
})
|
||||
|
||||
const systemManagedTab = computed(() => activeTab.value === 'thumbnails' || activeTab.value === 'postThumbnails')
|
||||
const systemManagedTab = computed(() => ['thumbnails', 'postThumbnails', 'postBodyThumbnails'].includes(activeTab.value))
|
||||
|
||||
const activeSystemMediaCount = computed(() => activeTab.value === 'postThumbnails'
|
||||
? postThumbnailMediaItems.value.length
|
||||
: thumbnailMediaItems.value.length)
|
||||
const activeSystemMediaCount = computed(() => {
|
||||
if (activeTab.value === 'postThumbnails') {
|
||||
return postThumbnailMediaItems.value.length
|
||||
}
|
||||
|
||||
const activeSystemMediaTitle = computed(() => activeTab.value === 'postThumbnails' ? '카드 썸네일' : '프로필 이미지')
|
||||
if (activeTab.value === 'postBodyThumbnails') {
|
||||
return postBodyThumbnailMediaItems.value.length
|
||||
}
|
||||
|
||||
return thumbnailMediaItems.value.length
|
||||
})
|
||||
|
||||
const activeSystemMediaTitle = computed(() => {
|
||||
if (activeTab.value === 'postThumbnails') {
|
||||
return '카드 썸네일'
|
||||
}
|
||||
|
||||
if (activeTab.value === 'postBodyThumbnails') {
|
||||
return '본문 이미지'
|
||||
}
|
||||
|
||||
return '프로필 이미지'
|
||||
})
|
||||
|
||||
const activeSystemMediaHint = computed(() => {
|
||||
if (activeTab.value === 'postThumbnails') {
|
||||
return '게시물 목록 카드에서 쓰는 자동 생성 이미지입니다. 원본 대표 이미지가 사용 중이고 이 썸네일 파일이 있으면 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 썸네일은 사용 중으로 표시되며, 썸네일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.'
|
||||
}
|
||||
|
||||
if (activeTab.value === 'postBodyThumbnails') {
|
||||
return '게시물 본문을 빠르게 보여주기 위해 원본 이미지에서 자동 생성한 WebP 이미지입니다. 본문에는 이 파일을 우선 표시하고, 크게 보기에서는 원본 이미지를 사용합니다. 원본과 연결되어 있어 이름 변경과 폴더 이동은 제한됩니다.'
|
||||
}
|
||||
|
||||
return '회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제해도 디스크 파일은 삭제되지 않으며 이 목록에 남습니다. 목록이 바로 안 바뀌면 페이지를 새로고침하세요. 관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다.'
|
||||
})
|
||||
|
||||
@@ -250,9 +312,13 @@ const mediaUsageFilterOptions = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const visibleMediaUsageFilterOptions = computed(() => activeTab.value === 'library'
|
||||
? mediaUsageFilterOptions.value.filter((option) => option.id !== 'all')
|
||||
: mediaUsageFilterOptions.value)
|
||||
|
||||
/**
|
||||
* 상단 탭 전환 시 목록 상태를 초기화한다.
|
||||
* @param {'library' | 'postThumbnails' | 'thumbnails'} tab - 선택 탭
|
||||
* @param {'library' | 'postThumbnails' | 'postBodyThumbnails' | 'thumbnails'} tab - 선택 탭
|
||||
* @returns {void}
|
||||
*/
|
||||
const setActiveTab = (tab) => {
|
||||
@@ -286,6 +352,11 @@ const setMediaKindFilter = (kind) => {
|
||||
*/
|
||||
const setMediaUsageFilter = (unusedOnly) => {
|
||||
if (showUnusedOnly.value === unusedOnly) {
|
||||
if (unusedOnly) {
|
||||
showUnusedOnly.value = false
|
||||
clearMediaSelection()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -899,6 +970,36 @@ const regeneratePostThumbnail = async (item) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 원본 이미지의 본문 표시용 이미지를 다시 생성한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const regeneratePostBodyThumbnail = async (item) => {
|
||||
if (!canRegeneratePostBodyThumbnail(item)) {
|
||||
return
|
||||
}
|
||||
|
||||
regeneratingBodyThumbnailUrl.value = item.url
|
||||
|
||||
try {
|
||||
await $fetch('/admin/api/media/thumbnail', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
url: item.url,
|
||||
kind: 'body'
|
||||
}
|
||||
})
|
||||
await refresh()
|
||||
selectedMediaUrl.value = item.url
|
||||
showToast('success', '본문 이미지를 생성했습니다.')
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '본문 이미지를 생성하지 못했습니다.')
|
||||
} finally {
|
||||
regeneratingBodyThumbnailUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 미디어 중 삭제 가능한 항목을 한 번에 삭제한다.
|
||||
* @returns {Promise<void>}
|
||||
@@ -984,6 +1085,14 @@ watch(filteredMediaUrls, (urls) => {
|
||||
>
|
||||
카드 썸네일
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="activeTab === 'postBodyThumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
|
||||
type="button"
|
||||
@click="setActiveTab('postBodyThumbnails')"
|
||||
>
|
||||
본문 이미지
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="activeTab === 'thumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
|
||||
@@ -1041,7 +1150,7 @@ watch(filteredMediaUrls, (urls) => {
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-for="option in mediaUsageFilterOptions"
|
||||
v-for="option in visibleMediaUsageFilterOptions"
|
||||
:key="`usage-${option.id}`"
|
||||
class="admin-media__usage-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="showUnusedOnly === option.unusedOnly ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
|
||||
@@ -1405,6 +1514,37 @@ watch(filteredMediaUrls, (urls) => {
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="selectedMedia.bodyThumbnailStatus" class="admin-media__info-row">
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">본문 이미지</dt>
|
||||
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
|
||||
<span v-if="selectedMedia.bodyThumbnailStatus.role === 'body'">
|
||||
자동 생성된 본문 표시용 WebP 이미지입니다.
|
||||
</span>
|
||||
<template v-else>
|
||||
<span v-if="selectedMedia.bodyThumbnailStatus.hasThumbnail">
|
||||
생성됨: {{ selectedMedia.bodyThumbnailStatus.thumbnailUrl }}
|
||||
</span>
|
||||
<span v-else-if="selectedMedia.bodyThumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
|
||||
본문 이미지 없음. 현재 본문에서 원본 이미지를 불러옵니다.
|
||||
</span>
|
||||
<span v-else>
|
||||
본문 이미지 없음. 게시물 본문에서 사용되면 원본으로 대체됩니다.
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="selectedMedia.bodyThumbnailStatus.originalUrl" class="text-muted">
|
||||
원본: {{ selectedMedia.bodyThumbnailStatus.originalUrl }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canRegeneratePostBodyThumbnail(selectedMedia)"
|
||||
class="admin-media__body-thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="regeneratingBodyThumbnailUrl === selectedMedia.url"
|
||||
@click="regeneratePostBodyThumbnail(selectedMedia)"
|
||||
>
|
||||
{{ regeneratingBodyThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.bodyThumbnailStatus.hasThumbnail ? '본문 이미지 다시 생성' : '본문 이미지 생성') }}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="admin-media__category grid gap-2">
|
||||
|
||||
@@ -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 } : {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -121,32 +112,13 @@ const getPostsPageTo = (page) => ({
|
||||
|
||||
<PostCard v-for="post in postCards" :key="post.to" :post="post" />
|
||||
|
||||
<nav
|
||||
<SitePagination
|
||||
v-if="pagination.totalPages > 1"
|
||||
class="posts-index__pagination site-section"
|
||||
aria-label="게시물 페이지"
|
||||
>
|
||||
<div class="site-section-body mx-auto flex max-w-[720px] items-center justify-between gap-3 text-sm">
|
||||
<NuxtLink
|
||||
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
|
||||
:class="pagination.page <= 1 ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getPostsPageTo(Math.max(1, pagination.page - 1))"
|
||||
:aria-disabled="pagination.page <= 1"
|
||||
>
|
||||
이전
|
||||
</NuxtLink>
|
||||
<span class="site-muted">
|
||||
{{ pagination.page }} / {{ pagination.totalPages }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
|
||||
:class="pagination.page >= pagination.totalPages ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getPostsPageTo(Math.min(pagination.totalPages, pagination.page + 1))"
|
||||
:aria-disabled="pagination.page >= pagination.totalPages"
|
||||
>
|
||||
다음
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
class="posts-index__pagination"
|
||||
:page="pagination.page"
|
||||
:total-pages="pagination.totalPages"
|
||||
label="게시물 페이지"
|
||||
base-path="/posts"
|
||||
/>
|
||||
</MainColumn>
|
||||
</template>
|
||||
|
||||
@@ -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 } : {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -202,33 +193,14 @@ const getTagPageTo = (page) => ({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav
|
||||
<SitePagination
|
||||
v-if="pagination.totalPages > 1"
|
||||
class="tag-posts-pagination site-section"
|
||||
aria-label="태그 게시물 페이지"
|
||||
>
|
||||
<div class="site-section-body mx-auto flex max-w-[720px] items-center justify-between gap-3 text-sm">
|
||||
<NuxtLink
|
||||
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
|
||||
:class="pagination.page <= 1 ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getTagPageTo(Math.max(1, pagination.page - 1))"
|
||||
:aria-disabled="pagination.page <= 1"
|
||||
>
|
||||
이전
|
||||
</NuxtLink>
|
||||
<span class="site-muted">
|
||||
{{ pagination.page }} / {{ pagination.totalPages }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
|
||||
:class="pagination.page >= pagination.totalPages ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getTagPageTo(Math.min(pagination.totalPages, pagination.page + 1))"
|
||||
:aria-disabled="pagination.page >= pagination.totalPages"
|
||||
>
|
||||
다음
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
class="tag-posts-pagination"
|
||||
:page="pagination.page"
|
||||
:total-pages="pagination.totalPages"
|
||||
label="태그 게시물 페이지"
|
||||
:base-path="`/tag/${slug}`"
|
||||
/>
|
||||
|
||||
<section v-if="tagPosts.length === 0" class="tag-posts-empty site-section">
|
||||
<div class="tag-posts-empty__body site-section-body mx-auto max-w-[720px] text-sm site-muted">
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../../lib/display-count-settings.js'
|
||||
import { normalizeSocialLinks } from '../../lib/social-links.js'
|
||||
import {
|
||||
createPostBodyThumbnailsForContent,
|
||||
createPostThumbnailForImageUrl,
|
||||
getExistingPostThumbnailUrl
|
||||
} from '../utils/post-thumbnail-image.js'
|
||||
@@ -553,7 +554,10 @@ export const createAdminPost = async (input, authorId) => {
|
||||
return insertedRows
|
||||
})
|
||||
|
||||
await createPostThumbnailForImageUrl(input.featuredImage)
|
||||
await Promise.all([
|
||||
createPostThumbnailForImageUrl(input.featuredImage),
|
||||
createPostBodyThumbnailsForContent(input.content)
|
||||
])
|
||||
|
||||
return getAdminPostById(rows[0].id)
|
||||
}
|
||||
@@ -606,7 +610,10 @@ export const updateAdminPost = async (id, input, editorId) => {
|
||||
})
|
||||
|
||||
if (rows[0]) {
|
||||
await createPostThumbnailForImageUrl(input.featuredImage)
|
||||
await Promise.all([
|
||||
createPostThumbnailForImageUrl(input.featuredImage),
|
||||
createPostBodyThumbnailsForContent(input.content)
|
||||
])
|
||||
}
|
||||
|
||||
return rows[0] ? getAdminPostById(rows[0].id) : null
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { createPostThumbnailForImageUrl } from '../../../../utils/post-thumbnail-image.js'
|
||||
import {
|
||||
createPostBodyThumbnailForImageUrl,
|
||||
createPostThumbnailForImageUrl
|
||||
} from '../../../../utils/post-thumbnail-image.js'
|
||||
|
||||
/**
|
||||
* 게시물 대표 이미지 카드 썸네일 재생성 API
|
||||
* 게시물 이미지 파생 파일 재생성 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ thumbnailUrl: string }>} 생성된 썸네일 URL
|
||||
*/
|
||||
@@ -11,12 +14,17 @@ export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
const thumbnailUrl = await createPostThumbnailForImageUrl(body?.url)
|
||||
const kind = body?.kind === 'body' ? 'body' : 'card'
|
||||
const thumbnailUrl = kind === 'body'
|
||||
? await createPostBodyThumbnailForImageUrl(body?.url)
|
||||
: await createPostThumbnailForImageUrl(body?.url)
|
||||
|
||||
if (!thumbnailUrl) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '카드 썸네일을 만들 수 있는 게시물 대표 이미지가 아닙니다.'
|
||||
message: kind === 'body'
|
||||
? '본문 표시용 이미지를 만들 수 있는 게시물 이미지가 아닙니다.'
|
||||
: '카드 썸네일을 만들 수 있는 게시물 대표 이미지가 아닙니다.'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import { createError } from 'h3'
|
||||
import { getSiteSettings, listAdminPosts, listAdminTags, listPages } from '../repositories/content-repository'
|
||||
import { getPostgresClient } from '../repositories/postgres-client'
|
||||
import {
|
||||
getPostBodyThumbnailUrl,
|
||||
getPostThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl,
|
||||
isPostThumbnailSource
|
||||
} from './post-thumbnail-image.js'
|
||||
@@ -85,12 +87,13 @@ const assertCategoryMoveAllowed = (urls, normalizedCategory) => {
|
||||
const uniqueUrls = [...new Set(urls.filter(Boolean))]
|
||||
const hasAvatar = uniqueUrls.some((u) => isMemberAvatarPublicUrl(u))
|
||||
const hasPostCardThumbnail = uniqueUrls.some((u) => isPostCardThumbnailUrl(u))
|
||||
const hasPostBodyThumbnail = uniqueUrls.some((u) => isPostBodyThumbnailUrl(u))
|
||||
const hasNonAvatar = uniqueUrls.some((u) => !isMemberAvatarPublicUrl(u))
|
||||
|
||||
if (hasPostCardThumbnail) {
|
||||
if (hasPostCardThumbnail || hasPostBodyThumbnail) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '게시물 카드 썸네일은 원본 이미지에 연결된 자동 생성 파일이라 폴더를 변경할 수 없습니다.'
|
||||
message: '게시물 자동 생성 이미지는 원본 이미지에 연결된 파일이라 폴더를 변경할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -542,29 +545,53 @@ const getDirectMediaUsage = (url, posts, pages, siteSettings, tags = []) => [
|
||||
...getTagMediaUsage(url, tags)
|
||||
]
|
||||
|
||||
/**
|
||||
* 게시물 파생 이미지 URL별 원본 URL 맵을 만든다.
|
||||
* @param {Array<Object>} items - 미디어 항목 목록
|
||||
* @param {(url: string) => string} getDerivativeUrl - 원본 URL에서 파생 URL을 만드는 함수
|
||||
* @param {(url: string) => boolean} isDerivativeUrl - 파생 URL 여부 확인 함수
|
||||
* @returns {Map<string, string>} 파생 이미지 URL별 원본 URL
|
||||
*/
|
||||
const buildPostDerivativeOwnerMap = (items, getDerivativeUrl, isDerivativeUrl) => {
|
||||
const itemUrlSet = new Set(items.map((item) => item.url))
|
||||
const ownerByDerivativeUrl = new Map()
|
||||
|
||||
for (const item of items) {
|
||||
if (isDerivativeUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const derivativeUrl = getDerivativeUrl(item.url)
|
||||
|
||||
if (derivativeUrl && itemUrlSet.has(derivativeUrl)) {
|
||||
ownerByDerivativeUrl.set(derivativeUrl, item.url)
|
||||
}
|
||||
}
|
||||
|
||||
return ownerByDerivativeUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 썸네일 URL별 원본 URL 맵을 만든다.
|
||||
* @param {Array<Object>} items - 미디어 항목 목록
|
||||
* @returns {Map<string, string>} 썸네일 URL별 원본 URL
|
||||
*/
|
||||
const buildPostThumbnailOwnerMap = (items) => {
|
||||
const itemUrlSet = new Set(items.map((item) => item.url))
|
||||
const ownerByThumbnailUrl = new Map()
|
||||
const buildPostThumbnailOwnerMap = (items) => buildPostDerivativeOwnerMap(
|
||||
items,
|
||||
getPostThumbnailUrl,
|
||||
isPostCardThumbnailUrl
|
||||
)
|
||||
|
||||
for (const item of items) {
|
||||
if (isPostCardThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const thumbnailUrl = getPostThumbnailUrl(item.url)
|
||||
|
||||
if (thumbnailUrl && itemUrlSet.has(thumbnailUrl)) {
|
||||
ownerByThumbnailUrl.set(thumbnailUrl, item.url)
|
||||
}
|
||||
}
|
||||
|
||||
return ownerByThumbnailUrl
|
||||
}
|
||||
/**
|
||||
* 본문 표시용 이미지 URL별 원본 URL 맵을 만든다.
|
||||
* @param {Array<Object>} items - 미디어 항목 목록
|
||||
* @returns {Map<string, string>} 본문 표시용 이미지 URL별 원본 URL
|
||||
*/
|
||||
const buildPostBodyThumbnailOwnerMap = (items) => buildPostDerivativeOwnerMap(
|
||||
items,
|
||||
getPostBodyThumbnailUrl,
|
||||
isPostBodyThumbnailUrl
|
||||
)
|
||||
|
||||
/**
|
||||
* 게시물 카드 썸네일의 간접 사용처를 조회한다.
|
||||
@@ -592,6 +619,32 @@ const getPostThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages, site
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 본문 표시용 이미지의 간접 사용처를 조회한다.
|
||||
* @param {string} url - 본문 표시용 이미지 URL
|
||||
* @param {Map<string, string>} ownerByThumbnailUrl - 본문 표시용 이미지 URL별 원본 URL
|
||||
* @param {Array<Object>} posts - 게시물 목록
|
||||
* @param {Array<Object>} pages - 페이지 목록
|
||||
* @param {Object} siteSettings - 사이트 설정
|
||||
* @returns {Array<Object>} 간접 사용처 목록
|
||||
*/
|
||||
const getPostBodyThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages, siteSettings) => {
|
||||
const originalUrl = ownerByThumbnailUrl.get(url)
|
||||
|
||||
if (!originalUrl) {
|
||||
return []
|
||||
}
|
||||
|
||||
return getDirectMediaUsage(originalUrl, posts, pages, siteSettings)
|
||||
.filter((usage) => usage.location === 'content')
|
||||
.map((usage) => ({
|
||||
...usage,
|
||||
location: 'contentBodyImage',
|
||||
label: '본문 표시 이미지',
|
||||
sourceUrl: originalUrl
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 업로드 이미지의 카드 썸네일 상태를 조회한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
@@ -600,7 +653,7 @@ const getPostThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages, site
|
||||
* @returns {Object|null} 카드 썸네일 상태
|
||||
*/
|
||||
const getPostThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||
if (isPostCardThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||
if (isPostCardThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -621,6 +674,35 @@ const getPostThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 업로드 이미지의 본문 표시용 이미지 상태를 조회한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @param {Set<string>} itemUrlSet - 전체 미디어 URL 집합
|
||||
* @param {Array<Object>} directUsage - 원본 URL 직접 사용처
|
||||
* @returns {Object|null} 본문 표시용 이미지 상태
|
||||
*/
|
||||
const getPostBodyThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||
if (isPostCardThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const thumbnailUrl = getPostBodyThumbnailUrl(item.url)
|
||||
|
||||
if (!thumbnailUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasThumbnail = itemUrlSet.has(thumbnailUrl)
|
||||
const isContentImage = directUsage.some((usage) => usage.location === 'content')
|
||||
|
||||
return {
|
||||
role: 'original',
|
||||
thumbnailUrl,
|
||||
hasThumbnail,
|
||||
isFallbackActive: isContentImage && !hasThumbnail
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 설정에서 미디어 URL 사용처 조회
|
||||
* @param {string} url - 미디어 URL
|
||||
@@ -705,20 +787,24 @@ export const listMediaItems = async () => {
|
||||
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
|
||||
const itemUrlSet = new Set(items.map((item) => item.url))
|
||||
const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items)
|
||||
const bodyThumbnailOwnerByUrl = buildPostBodyThumbnailOwnerMap(items)
|
||||
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 thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||
const bodyThumbnailUsage = getPostBodyThumbnailMediaUsage(item.url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||
const originalUrl = thumbnailOwnerByUrl.get(item.url) || ''
|
||||
const bodyOriginalUrl = bodyThumbnailOwnerByUrl.get(item.url) || ''
|
||||
|
||||
return {
|
||||
...item,
|
||||
category,
|
||||
usage: [
|
||||
...directUsage,
|
||||
...thumbnailUsage
|
||||
...thumbnailUsage,
|
||||
...bodyThumbnailUsage
|
||||
],
|
||||
thumbnailStatus: originalUrl
|
||||
? {
|
||||
@@ -726,6 +812,12 @@ export const listMediaItems = async () => {
|
||||
originalUrl
|
||||
}
|
||||
: getPostThumbnailStatus(item, itemUrlSet, directUsage),
|
||||
bodyThumbnailStatus: bodyOriginalUrl
|
||||
? {
|
||||
role: 'body',
|
||||
originalUrl: bodyOriginalUrl
|
||||
}
|
||||
: getPostBodyThumbnailStatus(item, itemUrlSet, directUsage),
|
||||
avatarOwner
|
||||
}
|
||||
})
|
||||
@@ -848,9 +940,11 @@ export const deleteMediaItem = async (url) => {
|
||||
getSiteSettings(),
|
||||
listAdminTags()
|
||||
])
|
||||
const items = await readMediaDirectory(uploadRoot)
|
||||
const usage = [
|
||||
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
|
||||
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(await readMediaDirectory(uploadRoot)), posts, pages, siteSettings)
|
||||
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings),
|
||||
...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings)
|
||||
]
|
||||
|
||||
if (usage.length) {
|
||||
@@ -878,10 +972,10 @@ export const deleteMediaItem = async (url) => {
|
||||
* @returns {Promise<Object>} 변경된 미디어 항목
|
||||
*/
|
||||
export const renameMediaItem = async (url, name) => {
|
||||
if (isPostCardThumbnailUrl(url)) {
|
||||
if (isPostCardThumbnailUrl(url) || isPostBodyThumbnailUrl(url)) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '게시물 카드 썸네일은 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다.'
|
||||
message: '게시물 자동 생성 이미지는 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -891,9 +985,11 @@ export const renameMediaItem = async (url, name) => {
|
||||
getSiteSettings(),
|
||||
listAdminTags()
|
||||
])
|
||||
const items = await readMediaDirectory(uploadRoot)
|
||||
const usage = [
|
||||
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
|
||||
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(await readMediaDirectory(uploadRoot)), posts, pages, siteSettings)
|
||||
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings),
|
||||
...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings)
|
||||
]
|
||||
|
||||
if (usage.length) {
|
||||
|
||||
@@ -2,15 +2,22 @@ import { existsSync } from 'node:fs'
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import { dirname, extname, join, posix } from 'node:path'
|
||||
import sharp from 'sharp'
|
||||
import { isPostCardThumbnailUrl } from '../../lib/post-card-thumbnail-url.js'
|
||||
import {
|
||||
getPostBodyThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl
|
||||
} from '../../lib/post-card-thumbnail-url.js'
|
||||
|
||||
export { isPostCardThumbnailUrl }
|
||||
export { getPostBodyThumbnailUrl, isPostBodyThumbnailUrl, isPostCardThumbnailUrl }
|
||||
|
||||
export const POST_THUMBNAIL_WIDTH = 640
|
||||
export const POST_THUMBNAIL_HEIGHT = 360
|
||||
export const POST_THUMBNAIL_QUALITY = 82
|
||||
export const POST_BODY_IMAGE_WIDTH = 1600
|
||||
export const POST_BODY_IMAGE_QUALITY = 84
|
||||
|
||||
const postUploadUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^/?#]+$/i
|
||||
const postUploadImageUrlPattern = /\/uploads\/posts\/\d{4}\/\d{2}\/[^/\s"'()<>\]]+\.(?:jpe?g|png|webp)/gi
|
||||
const postThumbnailDirectoryName = 'thumbs'
|
||||
const postThumbnailSuffix = '-card'
|
||||
const postThumbnailExtension = '.webp'
|
||||
@@ -92,6 +99,21 @@ export const getPostThumbnailDiskPath = (imageUrl) => {
|
||||
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 본문 표시용 이미지 URL의 디스크 경로를 조회한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
* @returns {string} 본문 표시용 이미지 디스크 경로
|
||||
*/
|
||||
export const getPostBodyThumbnailDiskPath = (imageUrl) => {
|
||||
const thumbnailUrl = getPostBodyThumbnailUrl(imageUrl)
|
||||
|
||||
if (!thumbnailUrl) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 원본 이미지 URL의 디스크 경로를 조회한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
@@ -138,6 +160,21 @@ export const getExistingPostThumbnailUrl = (imageUrl) => {
|
||||
return existsSync(getPostThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 생성된 게시물 본문 표시용 이미지 URL을 조회한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
* @returns {string} 존재하는 본문 표시용 이미지 URL
|
||||
*/
|
||||
export const getExistingPostBodyThumbnailUrl = (imageUrl) => {
|
||||
const thumbnailUrl = getPostBodyThumbnailUrl(imageUrl)
|
||||
|
||||
if (!thumbnailUrl) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return existsSync(getPostBodyThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 대표 이미지의 카드 썸네일을 생성하거나 다시 생성한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
@@ -174,3 +211,73 @@ export const createPostThumbnailForImageUrl = async (imageUrl) => {
|
||||
|
||||
return thumbnailUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 본문 이미지의 표시용 WebP 이미지를 생성하거나 다시 생성한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
* @returns {Promise<string>} 생성된 본문 표시용 이미지 URL
|
||||
*/
|
||||
export const createPostBodyThumbnailForImageUrl = async (imageUrl) => {
|
||||
const sourcePath = getPostImageDiskPath(imageUrl)
|
||||
const thumbnailUrl = getPostBodyThumbnailUrl(imageUrl)
|
||||
const thumbnailPath = getPostBodyThumbnailDiskPath(imageUrl)
|
||||
|
||||
if (!sourcePath || !thumbnailUrl || !thumbnailPath) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(sourcePath)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(dirname(thumbnailPath), { recursive: true })
|
||||
|
||||
await sharp(sourcePath)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: POST_BODY_IMAGE_WIDTH,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.webp({ quality: POST_BODY_IMAGE_QUALITY })
|
||||
.toFile(thumbnailPath)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
return thumbnailUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 본문에서 업로드 이미지 URL을 추출한다.
|
||||
* @param {string|null|undefined} content - 게시물 본문 마크다운
|
||||
* @returns {Array<string>} 중복 제거된 이미지 URL 목록
|
||||
*/
|
||||
export const extractPostImageUrlsFromContent = (content) => {
|
||||
const matches = String(content || '').match(postUploadImageUrlPattern) || []
|
||||
|
||||
return [...new Set(matches)]
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 본문에 포함된 이미지의 표시용 WebP 이미지를 생성한다.
|
||||
* @param {string|null|undefined} content - 게시물 본문 마크다운
|
||||
* @returns {Promise<Array<string>>} 생성된 본문 표시용 이미지 URL 목록
|
||||
*/
|
||||
export const createPostBodyThumbnailsForContent = async (content) => {
|
||||
const imageUrls = extractPostImageUrlsFromContent(content)
|
||||
const generatedUrls = []
|
||||
|
||||
for (const imageUrl of imageUrls) {
|
||||
const thumbnailUrl = await createPostBodyThumbnailForImageUrl(imageUrl)
|
||||
|
||||
if (thumbnailUrl) {
|
||||
generatedUrls.push(thumbnailUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return generatedUrls
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user