본문 이미지 최적화·라이트박스·번호형 페이지네이션 반영 (v1.10.3)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-01 16:19:45 +09:00
parent 0eca6b288e
commit c686b1c848
20 changed files with 812 additions and 143 deletions

View File

@@ -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) {