대표 이미지 전용 카드 썸네일로 정리

This commit is contained in:
2026-06-08 15:54:39 +09:00
parent eb4018f92c
commit 806b181d1f
18 changed files with 236 additions and 147 deletions

View File

@@ -21,7 +21,10 @@ import {
parseSignupBlockedUsernamesFromDb
} from '../../lib/signup-blocked-usernames.js'
import { normalizeSocialLinks } from '../../lib/social-links.js'
import { getExistingPostThumbnailUrl } from '../utils/post-thumbnail-image.js'
import {
createPostThumbnailForImageUrl,
getExistingPostThumbnailUrl
} from '../utils/post-thumbnail-image.js'
import { getPostgresClient } from './postgres-client'
/**
@@ -38,6 +41,7 @@ const mapPostRow = (row) => ({
excerpt: row.excerpt,
featuredImage: row.featured_image,
featuredImageThumbnail: getExistingPostThumbnailUrl(row.featured_image),
showFeaturedImage: Boolean(row.show_featured_image),
isFeatured: Boolean(row.is_featured),
commentCount: Number(row.comment_count || 0),
seoTitle: row.seo_title || '',
@@ -361,6 +365,7 @@ export const createAdminPost = async (input, authorId) => {
content,
excerpt,
featured_image,
show_featured_image,
is_featured,
seo_title,
seo_description,
@@ -377,6 +382,7 @@ export const createAdminPost = async (input, authorId) => {
${input.content},
${input.excerpt},
${input.featuredImage},
${input.showFeaturedImage},
${input.isFeatured},
${input.seoTitle},
${input.seoDescription},
@@ -394,6 +400,8 @@ export const createAdminPost = async (input, authorId) => {
return insertedRows
})
await createPostThumbnailForImageUrl(input.featuredImage)
return getAdminPostById(rows[0].id)
}
@@ -421,6 +429,7 @@ export const updateAdminPost = async (id, input, editorId) => {
content = ${input.content},
excerpt = ${input.excerpt},
featured_image = ${input.featuredImage},
show_featured_image = ${input.showFeaturedImage},
is_featured = ${input.isFeatured},
seo_title = ${input.seoTitle},
seo_description = ${input.seoDescription},
@@ -443,6 +452,10 @@ export const updateAdminPost = async (id, input, editorId) => {
return updatedRows
})
if (rows[0]) {
await createPostThumbnailForImageUrl(input.featuredImage)
}
return rows[0] ? getAdminPostById(rows[0].id) : null
}

View File

@@ -0,0 +1,26 @@
import { createError, readBody } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { createPostThumbnailForImageUrl } from '../../../../utils/post-thumbnail-image.js'
/**
* 게시물 대표 이미지 카드 썸네일 재생성 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ thumbnailUrl: string }>} 생성된 썸네일 URL
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const body = await readBody(event)
const thumbnailUrl = await createPostThumbnailForImageUrl(body?.url)
if (!thumbnailUrl) {
throw createError({
statusCode: 400,
message: '카드 썸네일을 만들 수 있는 게시물 대표 이미지가 아닙니다.'
})
}
return {
thumbnailUrl
}
})

View File

@@ -1,7 +1,6 @@
import { mkdir, stat, writeFile } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import {
buildDefaultUploadSizeLimits,
formatUploadSizeLimit,
@@ -12,13 +11,6 @@ import {
import { requireAdminSession } from '../../../utils/admin-auth'
import { getRuntimeEnvNumber } from '../../../utils/runtime-env.js'
import { upsertMediaMetadataCategory } from '../../../utils/media-library'
import {
POST_THUMBNAIL_HEIGHT,
POST_THUMBNAIL_QUALITY,
POST_THUMBNAIL_WIDTH,
getPostThumbnailFileName,
isPostThumbnailSource
} from '../../../utils/post-thumbnail-image.js'
const allowedUploadTypes = new Map([
['image/jpeg', '.jpg'],
@@ -126,38 +118,6 @@ const pickUniqueDiskFileName = async (directoryPath, stem, extension) => {
})
}
/**
* 게시물 목록용 카드 썸네일을 생성한다.
* @param {Object} options - 생성 옵션
* @param {Buffer} options.sourceBuffer - 원본 이미지 버퍼
* @param {string} options.directoryPath - 원본 이미지 저장 디렉터리
* @param {string} options.fileName - 원본 파일명
* @returns {Promise<string>} 생성된 썸네일 파일명
*/
const createPostCardThumbnail = async ({ sourceBuffer, directoryPath, fileName }) => {
const thumbnailDirectoryPath = join(directoryPath, 'thumbs')
const thumbnailFileName = getPostThumbnailFileName(fileName)
const thumbnailPath = join(thumbnailDirectoryPath, thumbnailFileName)
await mkdir(thumbnailDirectoryPath, { recursive: true })
const thumbnailBuffer = await sharp(sourceBuffer)
.rotate()
.resize({
width: POST_THUMBNAIL_WIDTH,
height: POST_THUMBNAIL_HEIGHT,
fit: 'cover',
position: 'centre',
withoutEnlargement: true
})
.webp({ quality: POST_THUMBNAIL_QUALITY })
.toBuffer()
await writeFile(thumbnailPath, thumbnailBuffer)
return thumbnailFileName
}
/**
* 관리자 미디어 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -219,22 +179,11 @@ export default defineEventHandler(async (event) => {
await writeFile(filePath, file.data)
const publicUrl = `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`
let thumbnailUrl = ''
if (isPostThumbnailSource(file.type, fileName)) {
const thumbnailFileName = await createPostCardThumbnail({
sourceBuffer: file.data,
directoryPath,
fileName
})
thumbnailUrl = `${uploadBaseUrl}/posts/${year}/${month}/thumbs/${thumbnailFileName}`
}
await upsertMediaMetadataCategory(publicUrl, '미분류')
uploadedFiles.push({
url: publicUrl,
thumbnailUrl,
name: fileName,
size: file.data.length
})

View File

@@ -12,6 +12,7 @@ export const adminPostInputSchema = z.object({
content: z.preprocess(normalizeMarkdownContent, z.string()).default(''),
excerpt: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null),
showFeaturedImage: z.boolean().default(false),
isFeatured: z.boolean().default(false),
seoTitle: z.string().trim().default(''),
seoDescription: z.string().trim().default(''),

View File

@@ -1,5 +1,7 @@
import { existsSync } from 'node:fs'
import { mkdir, stat } from 'node:fs/promises'
import { dirname, extname, join, posix } from 'node:path'
import sharp from 'sharp'
export const POST_THUMBNAIL_WIDTH = 640
export const POST_THUMBNAIL_HEIGHT = 360
@@ -95,6 +97,19 @@ export const getPostThumbnailDiskPath = (imageUrl) => {
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
}
/**
* 게시물 원본 이미지 URL의 디스크 경로를 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 원본 이미지 디스크 경로
*/
export const getPostImageDiskPath = (imageUrl) => {
if (!imageUrl || !postUploadUrlPattern.test(imageUrl)) {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(imageUrl.replace(/^\/+/, '')))
}
/**
* 게시물 원본 이미지 파일의 썸네일 저장 디렉터리 경로를 조회한다.
* @param {string} imageFilePath - 원본 이미지 디스크 경로
@@ -127,3 +142,40 @@ export const getExistingPostThumbnailUrl = (imageUrl) => {
return existsSync(getPostThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
}
/**
* 게시물 대표 이미지의 카드 썸네일을 생성하거나 다시 생성한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {Promise<string>} 생성된 카드 썸네일 URL
*/
export const createPostThumbnailForImageUrl = async (imageUrl) => {
const sourcePath = getPostImageDiskPath(imageUrl)
const thumbnailUrl = getPostThumbnailUrl(imageUrl)
const thumbnailPath = getPostThumbnailDiskPath(imageUrl)
if (!sourcePath || !thumbnailUrl || !thumbnailPath) {
return ''
}
try {
await stat(sourcePath)
} catch {
return ''
}
await mkdir(dirname(thumbnailPath), { recursive: true })
await sharp(sourcePath)
.rotate()
.resize({
width: POST_THUMBNAIL_WIDTH,
height: POST_THUMBNAIL_HEIGHT,
fit: 'cover',
position: 'centre',
withoutEnlargement: true
})
.webp({ quality: POST_THUMBNAIL_QUALITY })
.toFile(thumbnailPath)
return thumbnailUrl
}