게시물 목록 카드 썸네일 생성 추가

This commit is contained in:
2026-06-08 14:43:09 +09:00
parent 7a357dcabc
commit 664d2f98aa
17 changed files with 330 additions and 10 deletions

View File

@@ -14,6 +14,7 @@ defineProps({
:to="post.to"
:title="post.title"
:featured-image="post.featuredImage"
:thumbnail-image="post.featuredImageThumbnail"
link-class="h-20 w-36 shrink-0"
aspect-class="h-full w-full"
/>

View File

@@ -1,5 +1,5 @@
<script setup>
defineProps({
const props = defineProps({
/** 게시물 링크 */
to: {
type: String,
@@ -15,6 +15,11 @@ defineProps({
type: String,
default: ''
},
/** 목록 표시용 대표 이미지 썸네일 URL */
thumbnailImage: {
type: String,
default: ''
},
/** 썸네일 비율·크기 Tailwind 클래스 */
aspectClass: {
type: String,
@@ -31,6 +36,8 @@ defineProps({
default: ''
}
})
const displayImage = computed(() => props.thumbnailImage || props.featuredImage)
</script>
<template>
@@ -42,10 +49,10 @@ defineProps({
>
<figure class="post-card-media__figure overflow-hidden rounded-[10px]">
<img
v-if="featuredImage"
v-if="displayImage"
class="post-card-media__image w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
:class="[aspectClass, imageClass]"
:src="featuredImage"
:src="displayImage"
:alt="title"
loading="lazy"
>

View File

@@ -1,5 +1,10 @@
# 업데이트 요약
## v1.5.78
- 게시물 목록 카드에서 원본 대표 이미지 대신 생성된 카드용 썸네일을 우선 사용하도록 개선했다.
- 기존 업로드 이미지도 한 번에 카드용 썸네일로 변환할 수 있는 백필 명령을 추가했다.
## v1.5.77
- 메인 화면 Latest 목록에서 긴 설명 때문에 메타 정보가 잘리는 문제를 줄였다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.5.77에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.5.78에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -16,6 +16,13 @@
## 로컬 개발
### v1.5.78 참고
- 추가 DB 마이그레이션은 없다.
- 새 게시물 이미지 업로드 시 `/public/uploads/posts/YYYY/MM/thumbs/*-card.webp` 카드 썸네일이 함께 생성되는지 확인한다.
- 기존 업로드 이미지가 많은 운영 환경에서는 배포 후 `npm run images:backfill-post-thumbnails`를 한 번 실행해 누락된 카드 썸네일을 생성한다.
- 메인 Featured·Latest, 게시물 목록, 태그 목록에서 생성된 썸네일 URL을 우선 요청하고, 썸네일이 없는 외부/기존 이미지는 원본으로 대체되는지 확인한다.
### v1.5.77 참고
- 추가 DB 마이그레이션은 없다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-08 v1.5.78 — 공개 목록 이미지는 원본 대신 카드 썸네일을 우선 사용한다
대표 이미지는 상세 화면, OG 이미지, RSS 같은 원본 품질이 필요한 경로에서도 쓰인다. 하지만 메인·목록·태그 카드에서는 작은 썸네일만 필요하므로 사이트 접속만으로 큰 원본 이미지를 내려받는 비용이 크다. 업로드 시점에 640×360 WebP 카드 썸네일을 함께 만들고, 공개 목록 응답에는 파일이 실제로 존재할 때만 `featuredImageThumbnail`을 추가해 기존 이미지와 외부 URL의 fallback을 유지한다. 기존 업로드 파일은 운영 볼륨에 저장되므로 저장소에 포함하지 않고 백필 명령으로 생성한다.
## 2026-06-08 v1.5.77 — Latest 목록은 기존 썸네일 레이아웃을 유지하며 메타 잘림을 줄인다
메인 Latest 목록은 빠르게 훑는 영역이므로 설명이 길어질 때 메타 정보가 잘려 보이면 스캔성이 떨어진다. 썸네일 레이아웃을 바꾸면 기존 compact 보기의 균형이 깨질 수 있으므로 썸네일 구조는 유지하고, 요약 영역이 메타 행을 밀어내지 않도록 최소 범위에서 조정한다.

View File

@@ -51,6 +51,7 @@
| 파일 | 용도 |
|------|------|
| scripts/check-js-syntax.js | `npm run lint`에서 JS/MJS/CJS 파일을 `node --check`로 문법 점검 |
| scripts/backfill-post-thumbnails.js | 기존 `public/uploads/posts` 이미지의 목록 카드용 WebP 썸네일 백필 |
## 서버 미들웨어
@@ -63,6 +64,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/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 |
## 사이트 컴포넌트
@@ -81,7 +83,7 @@
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
| components/site/PostCardMedia.vue | 게시물 카드 썸네일(대표 이미지 없으면 모바일 3줄·`sm` 이상 4줄 제목 placeholder 표시) |
| components/site/PostCardMedia.vue | 게시물 카드 썸네일(`featuredImageThumbnail` 우선, 없으면 대표 이미지 원본 사용. 대표 이미지 없으면 모바일 3줄·`sm` 이상 4줄 제목 placeholder 표시) |
| components/site/TagHeader.vue | 태그 페이지 헤더 |
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 입력값 기반 등록 버튼 활성화, 작성자 썸네일/좋아요/상대시간 표시 |

View File

@@ -56,6 +56,7 @@
- 가로 카드 트랙은 `overflow-x-auto``snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
- Featured 영역은 추천 글(`isFeatured=true`)이 1개 이상 있을 때만 표시한다.
- Featured 글에 대표 이미지가 없으면 목록 썸네일과 동일하게 카드 안에 게시물 제목을 표시하는 placeholder를 사용한다.
- Featured 대표 이미지가 게시물 업로드 이미지이고 카드 썸네일이 생성되어 있으면 원본 대신 `featuredImageThumbnail`을 우선 표시한다.
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
@@ -63,6 +64,7 @@
- 기본 보기 방식은 `compact`이며, Default 선택 시에도 `compact`로 복원한다.
- `compact`는 썸네일을 포함한 짧은 행 형태이며 설명은 최대 2줄로 제한한다. 모바일 compact 썸네일은 오른쪽 제목·요약·메타 높이에 맞춘 80px 정사각형으로 표시하고, `sm` 이상에서는 기존 비율형 썸네일을 사용한다. 대표 이미지가 없는 placeholder 제목은 모바일 3줄, `sm` 이상 4줄로 제한하며 긴 단어는 썸네일 안에서 줄바꿈한다. `list`는 텍스트 중심 목록 형태로 설명은 최대 2줄, `cards`는 카드 그리드 형태로 표시한다.
- 홈 Latest·게시물 목록·태그 목록의 카드 이미지는 `featuredImageThumbnail`을 우선 사용하고, 값이 없으면 기존 `featuredImage` 원본 URL로 대체한다.
- Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다.
- 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 메인 인피드 광고 코드가 있으면 Latest 게시물 목록 사이 한 곳에 브라우저 렌더 시점 기준으로 무작위 삽입한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다.
@@ -88,6 +90,13 @@
- 공유·SEO 설명은 SEO 설명이 있으면 우선 사용하고, 없으면 게시물 요약, 요약도 없으면 본문에서 마크다운 기호를 제거한 짧은 텍스트를 사용한다.
- 홈 Latest·게시물 목록·태그 목록의 카드 설명도 동일하게 요약이 비어 있으면 본문에서 `createPostSummary`로 짧은 텍스트를 만든다. 목록용 설명은 문자열에 수동 말줄임을 붙이지 않고 `post-summary-clamp` 전용 클래스가 실제 표시 줄 끝에서 말줄임을 처리한다.
### 게시물 업로드 이미지 썸네일
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한 뒤, 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 함께 생성한다.
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
- 공개 게시물 응답은 원본 대표 이미지 `featuredImage`를 유지하고, 대응 썸네일 파일이 실제로 존재할 때만 `featuredImageThumbnail`을 추가한다.
- 기존 업로드 이미지는 `npm run images:backfill-post-thumbnails``public/uploads/posts` 하위 파일을 스캔해 누락된 카드 썸네일을 생성한다.
### 공개 목록·상세의 발행일 표시
- API의 ISO 8601 `publishedAt`를 공개 UI에서는 로컬 날짜 기준 `YYYY.MM.DD`로 표시한다.
@@ -290,7 +299,7 @@ components/content/
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
> API 응답의 게시물 객체는 `isFeatured` `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
> API 응답의 게시물 객체는 원본 대표 이미지 `featuredImage`, 목록용 카드 썸네일 `featuredImageThumbnail`, `isFeatured`, `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
> 공개 게시물 목록·상세는 `published` 상태만 기본 노출하며, `members` 상태는 VIP 이상 등급(`vip`/`admin`/`owner`) 회원에게만 노출한다. `private`와 `draft`는 공개 화면에서 노출하지 않는다.
### PostExportJobs / PostExportFiles

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.5.78
- 게시물 이미지 업로드: JPG·PNG·WebP 업로드 시 목록 카드용 WebP 썸네일 자동 생성 추가.
- 공개 목록 API: 생성된 카드 썸네일이 있으면 `featuredImageThumbnail`로 함께 내려주도록 추가.
- 메인 Featured·Latest, 게시물 목록, 태그 목록: 카드 이미지는 썸네일을 우선 사용하고 없으면 원본 대표 이미지로 대체하도록 수정.
- 기존 업로드 이미지용 `npm run images:backfill-post-thumbnails` 백필 명령 추가.
## v1.5.77
- 메인 화면 Latest 목록: 기존 썸네일 레이아웃은 유지하고, 요약 영역이 메타 정보를 밀어내지 않도록 수정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.5.77",
"version": "1.5.78",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.77",
"version": "1.5.78",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.77",
"version": "1.5.78",
"private": true,
"type": "module",
"imports": {
@@ -22,6 +22,7 @@
"db:migrate:prod": "sh scripts/migrate-production-db.sh migrate",
"db:migrate:prod:status": "sh scripts/migrate-production-db.sh status",
"db:migrate:prod:baseline": "sh scripts/migrate-production-db.sh baseline",
"images:backfill-post-thumbnails": "node scripts/backfill-post-thumbnails.js",
"postinstall": "nuxt prepare"
},
"dependencies": {

View File

@@ -154,6 +154,7 @@ const mapLatestPost = (post) => {
appendEllipsis: false
}),
featuredImage: post.featuredImage,
featuredImageThumbnail: post.featuredImageThumbnail || '',
tagName: tagMeta.name,
tagColor: tagMeta.color,
publishedAt: formatPostDate(post.publishedAt),
@@ -354,7 +355,7 @@ watch([latestPosts, hasHomeInfeedAd], () => {
>
<img
v-if="post.featuredImage"
:src="post.featuredImage"
:src="post.featuredImageThumbnail || post.featuredImage"
:alt="post.title"
class="h-full w-full object-cover brightness-75 contrast-125 transition-all duration-200 group-hover:brightness-90 group-hover:contrast-110"
loading="lazy"
@@ -505,6 +506,7 @@ watch([latestPosts, hasHomeInfeedAd], () => {
:to="post.to"
:title="post.title"
:featured-image="post.featuredImage"
:thumbnail-image="post.featuredImageThumbnail"
:link-class="isPostFeedCards ? 'post-feed__media post-feed__media--cards mb-3 block aspect-video w-full' : 'post-feed__media post-feed__media--list relative h-20 w-20 shrink-0 self-start aspect-square sm:h-auto sm:w-auto sm:flex-1 sm:shrink sm:self-auto sm:min-w-16 sm:aspect-video'"
:aspect-class="isPostFeedCards ? 'aspect-video' : 'aspect-square sm:aspect-video'"
/>

View File

@@ -10,6 +10,7 @@ const postCards = computed(() => posts.value.map((post) => ({
appendEllipsis: false
}),
featuredImage: post.featuredImage,
featuredImageThumbnail: post.featuredImageThumbnail || '',
tag: post.tags?.[0] ? String(post.tags[0]).toUpperCase() : '',
publishedAt: formatPostDate(post.publishedAt),
to: `/post/${post.slug}`

View File

@@ -21,6 +21,7 @@ const tagPosts = computed(() => posts.value
appendEllipsis: false
}),
featuredImage: post.featuredImage,
featuredImageThumbnail: post.featuredImageThumbnail || '',
tag: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.name || (post.tags?.[0] || slug.value).toUpperCase(),
tagColor: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.color || '#4d4d4d',
isFeatured: Boolean(post.isFeatured),
@@ -59,6 +60,7 @@ const tagPosts = computed(() => posts.value
:to="post.to"
:title="post.title"
:featured-image="post.featuredImage"
:thumbnail-image="post.featuredImageThumbnail"
link-class="relative aspect-square min-w-16 flex-1 sm:aspect-video"
aspect-class="aspect-square w-full sm:aspect-video"
/>

View File

@@ -0,0 +1,98 @@
import { mkdir, readdir, stat } from 'node:fs/promises'
import { extname, join } from 'node:path'
import sharp from 'sharp'
import {
POST_THUMBNAIL_HEIGHT,
POST_THUMBNAIL_QUALITY,
POST_THUMBNAIL_WIDTH,
getPostThumbnailDirectoryPath,
getPostThumbnailPathForFile,
isPostThumbnailSource
} from '../server/utils/post-thumbnail-image.js'
const uploadRoot = join(process.cwd(), 'public', 'uploads', 'posts')
/**
* 게시물 업로드 이미지 파일 목록을 수집한다.
* @param {string} directoryPath - 탐색 디렉터리
* @returns {Promise<string[]>} 이미지 파일 경로 목록
*/
const collectPostImages = async (directoryPath) => {
let entries = []
try {
entries = await readdir(directoryPath, { withFileTypes: true })
} catch {
return []
}
const files = []
for (const entry of entries) {
const entryPath = join(directoryPath, entry.name)
if (entry.isDirectory()) {
if (entry.name === 'thumbs') {
continue
}
files.push(...await collectPostImages(entryPath))
continue
}
if (entry.isFile() && isPostThumbnailSource('', entry.name)) {
files.push(entryPath)
}
}
return files
}
/**
* 게시물 이미지 카드 썸네일을 생성한다.
* @param {string} imagePath - 원본 이미지 경로
* @returns {Promise<boolean>} 새로 생성했는지 여부
*/
const createPostThumbnail = async (imagePath) => {
const thumbnailPath = getPostThumbnailPathForFile(imagePath)
try {
await stat(thumbnailPath)
return false
} catch {
await mkdir(getPostThumbnailDirectoryPath(imagePath), { recursive: true })
}
await sharp(imagePath)
.rotate()
.resize({
width: POST_THUMBNAIL_WIDTH,
height: POST_THUMBNAIL_HEIGHT,
fit: 'cover',
position: 'centre',
withoutEnlargement: true
})
.webp({ quality: POST_THUMBNAIL_QUALITY })
.toFile(thumbnailPath)
return true
}
const images = await collectPostImages(uploadRoot)
let createdCount = 0
let skippedCount = 0
for (const imagePath of images) {
if (!isPostThumbnailSource('', imagePath) || extname(imagePath).toLowerCase() === '.gif') {
skippedCount += 1
continue
}
if (await createPostThumbnail(imagePath)) {
createdCount += 1
} else {
skippedCount += 1
}
}
process.stdout.write(`게시물 카드 썸네일 생성 완료: 생성 ${createdCount}개, 건너뜀 ${skippedCount}\n`)

View File

@@ -21,6 +21,7 @@ import {
parseSignupBlockedUsernamesFromDb
} from '../../lib/signup-blocked-usernames.js'
import { normalizeSocialLinks } from '../../lib/social-links.js'
import { getExistingPostThumbnailUrl } from '../utils/post-thumbnail-image.js'
import { getPostgresClient } from './postgres-client'
/**
@@ -36,6 +37,7 @@ const mapPostRow = (row) => ({
content: row.content,
excerpt: row.excerpt,
featuredImage: row.featured_image,
featuredImageThumbnail: getExistingPostThumbnailUrl(row.featured_image),
isFeatured: Boolean(row.is_featured),
commentCount: Number(row.comment_count || 0),
seoTitle: row.seo_title || '',

View File

@@ -1,6 +1,7 @@
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,
@@ -11,6 +12,13 @@ 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'],
@@ -118,6 +126,38 @@ 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 - 요청 이벤트
@@ -179,11 +219,22 @@ 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

@@ -0,0 +1,121 @@
import { existsSync } from 'node:fs'
import { dirname, extname, join, posix } from 'node:path'
export const POST_THUMBNAIL_WIDTH = 640
export const POST_THUMBNAIL_HEIGHT = 360
export const POST_THUMBNAIL_QUALITY = 82
const postUploadUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^?#]+$/i
const postThumbnailDirectoryName = 'thumbs'
const postThumbnailSuffix = '-card'
const postThumbnailExtension = '.webp'
const supportedPostThumbnailExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp'])
const supportedPostThumbnailTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
/**
* URL 경로 조각을 파일 시스템 경로용 문자열로 디코딩한다.
* @param {string} value - URL 경로 조각
* @returns {string} 디코딩된 값
*/
const decodeUrlPathPart = (value) => {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
/**
* 게시물 이미지에서 카드 썸네일을 생성할 수 있는지 확인한다.
* @param {string} mimeType - 파일 MIME 타입
* @param {string} fileName - 파일명
* @returns {boolean} 썸네일 생성 가능 여부
*/
export const isPostThumbnailSource = (mimeType, fileName = '') => {
if (supportedPostThumbnailTypes.has(mimeType)) {
return true
}
return supportedPostThumbnailExtensions.has(extname(fileName).toLowerCase())
}
/**
* 원본 파일명에서 게시물 카드 썸네일 파일명을 만든다.
* @param {string} fileName - 원본 파일명
* @returns {string} 썸네일 파일명
*/
export const getPostThumbnailFileName = (fileName) => {
const extension = extname(fileName)
const stem = extension ? fileName.slice(0, -extension.length) : fileName
return `${stem}${postThumbnailSuffix}${postThumbnailExtension}`
}
/**
* 게시물 원본 이미지 URL에 대응하는 카드 썸네일 URL을 만든다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 카드 썸네일 URL
*/
export const getPostThumbnailUrl = (imageUrl) => {
if (!imageUrl || !postUploadUrlPattern.test(imageUrl)) {
return ''
}
const directory = posix.dirname(imageUrl)
const fileName = posix.basename(imageUrl)
const extension = extname(fileName).toLowerCase()
if (!supportedPostThumbnailExtensions.has(extension)) {
return ''
}
return `${directory}/${postThumbnailDirectoryName}/${getPostThumbnailFileName(fileName)}`
}
/**
* 게시물 카드 썸네일 URL의 디스크 경로를 조회한다.
* @param {string} imageUrl - 원본 이미지 URL
* @returns {string} 썸네일 디스크 경로
*/
export const getPostThumbnailDiskPath = (imageUrl) => {
const thumbnailUrl = getPostThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
}
/**
* 게시물 원본 이미지 파일의 썸네일 저장 디렉터리 경로를 조회한다.
* @param {string} imageFilePath - 원본 이미지 디스크 경로
* @returns {string} 썸네일 저장 디렉터리 경로
*/
export const getPostThumbnailDirectoryPath = (imageFilePath) => join(dirname(imageFilePath), postThumbnailDirectoryName)
/**
* 게시물 원본 이미지 파일의 썸네일 저장 경로를 조회한다.
* @param {string} imageFilePath - 원본 이미지 디스크 경로
* @returns {string} 썸네일 저장 경로
*/
export const getPostThumbnailPathForFile = (imageFilePath) => {
const fileName = imageFilePath.split(/[\\/]/).pop() || ''
return join(getPostThumbnailDirectoryPath(imageFilePath), getPostThumbnailFileName(fileName))
}
/**
* 이미 생성된 게시물 카드 썸네일 URL을 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 존재하는 썸네일 URL
*/
export const getExistingPostThumbnailUrl = (imageUrl) => {
const thumbnailUrl = getPostThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return existsSync(getPostThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
}