게시물 목록 카드 썸네일 생성 추가
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.5.78
|
||||
|
||||
- 게시물 목록 카드에서 원본 대표 이미지 대신 생성된 카드용 썸네일을 우선 사용하도록 개선했다.
|
||||
- 기존 업로드 이미지도 한 번에 카드용 썸네일로 변환할 수 있는 백필 명령을 추가했다.
|
||||
|
||||
## v1.5.77
|
||||
|
||||
- 메인 화면 Latest 목록에서 긴 설명 때문에 메타 정보가 잘리는 문제를 줄였다.
|
||||
|
||||
@@ -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 마이그레이션은 없다.
|
||||
|
||||
@@ -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 보기의 균형이 깨질 수 있으므로 썸네일 구조는 유지하고, 요약 영역이 메타 행을 밀어내지 않도록 최소 범위에서 조정한다.
|
||||
|
||||
@@ -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단) 작성 및 목록 표시, 입력값 기반 등록 버튼 활성화, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||
|
||||
|
||||
11
docs/spec.md
11
docs/spec.md
@@ -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
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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'"
|
||||
/>
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
98
scripts/backfill-post-thumbnails.js
Normal file
98
scripts/backfill-post-thumbnails.js
Normal 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`)
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
121
server/utils/post-thumbnail-image.js
Normal file
121
server/utils/post-thumbnail-image.js
Normal 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 : ''
|
||||
}
|
||||
Reference in New Issue
Block a user