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

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

@@ -160,6 +160,7 @@ const form = reactive({
excerpt: props.initialPost.excerpt || '',
content: normalizeMarkdownContent(props.initialPost.content),
featuredImage: props.initialPost.featuredImage || '',
showFeaturedImage: Boolean(props.initialPost.showFeaturedImage),
isFeatured: Boolean(props.initialPost.isFeatured),
noindex: props.initialPost.noindex === true,
status: props.initialPost.status || 'draft',
@@ -602,6 +603,7 @@ const createPostPayload = (options = {}) => {
excerpt: form.excerpt.trim(),
content: normalizeMarkdownContent(form.content),
featuredImage: form.featuredImage.trim() || null,
showFeaturedImage: Boolean(form.featuredImage.trim() && form.showFeaturedImage),
isFeatured: form.isFeatured,
seoTitle: toAdminPostStoredTitle(form.title),
seoDescription: form.excerpt.trim(),
@@ -778,6 +780,7 @@ const applyPickedImage = () => {
*/
const removeFeaturedImage = () => {
form.featuredImage = ''
form.showFeaturedImage = false
}
/**
@@ -1898,6 +1901,35 @@ defineExpose({
</div>
</div>
<label
v-if="form.featuredImage"
class="admin-post-form__featured-image-display-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm"
>
<span class="admin-post-form__featured-image-display-copy flex min-w-0 items-center gap-3">
<span class="admin-post-form__featured-image-display-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m7 15 3-3 2 2 3-4 2 5" />
<circle cx="8" cy="9" r="1" />
</svg>
</span>
<span class="admin-post-form__featured-image-display-label grid min-w-0 gap-0.5">
<span class="font-bold text-[#15171a]">본문 상단 대표 이미지</span>
<span class="text-xs leading-snug text-[#8e9cac]">게시물 상세 제목 아래에 대표 이미지를 표시합니다.</span>
</span>
</span>
<span class="admin-post-form__featured-image-display-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="form.showFeaturedImage"
class="peer sr-only"
type="checkbox"
aria-label="본문 상단 대표 이미지 표시"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<label class="admin-post-form__featured-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
<span class="admin-post-form__featured-toggle-copy flex min-w-0 items-center gap-3">
<span class="admin-post-form__featured-toggle-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">

View File

@@ -0,0 +1,2 @@
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS show_featured_image BOOLEAN NOT NULL DEFAULT false;

View File

@@ -1,5 +1,12 @@
# 업데이트 요약
## v1.5.80
- 본문 첨부 이미지는 업로드만으로 카드 썸네일을 만들지 않도록 정리했다.
- 게시물 대표 이미지로 저장된 이미지에만 목록 카드용 썸네일을 생성한다.
- 관리자 미디어에서 누락된 대표 이미지 카드 썸네일을 다시 생성할 수 있게 했다.
- 게시물 상세의 제목 아래 대표 이미지는 글쓰기 옵션을 켠 경우에만 표시된다.
## v1.5.79
- 관리자 미디어에서 카드 썸네일을 별도 탭으로 분리했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.5.79에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.5.80에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -16,6 +16,15 @@
## 로컬 개발
### v1.5.80 참고
- DB 마이그레이션 `054_add_post_show_featured_image.sql` 적용 필요.
- 새 게시물 본문 이미지를 업로드해도 `/public/uploads/posts/YYYY/MM/thumbs/*-card.webp`가 바로 생성되지 않는지 확인한다.
- 게시물 대표 이미지를 설정하고 저장하면 해당 원본의 카드 썸네일이 생성되는지 확인한다.
- 대표 이미지 원본에 카드 썸네일이 없으면 관리자 미디어 상세에서 `카드 썸네일 생성` 버튼으로 다시 만들 수 있는지 확인한다.
- 게시물 상세 제목 아래 대표 이미지는 글쓰기 화면의 `본문 상단 대표 이미지` 옵션이 켜진 글에서만 표시되는지 확인한다.
- 운영 기존 업로드 중 대표 이미지 fallback 상태가 보이면 `npm run images:backfill-post-thumbnails`를 실행한다. 이 명령은 게시물 대표 이미지 URL만 대상으로 한다.
### v1.5.79 참고
- 추가 DB 마이그레이션은 없다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-08 v1.5.80 — 카드 썸네일 생성은 대표 이미지 저장 시점으로 제한한다
본문에 첨부한 모든 이미지는 사용자가 콘텐츠 일부로 올린 원본 자산이며, 목록 카드에 쓰이지 않을 수 있다. 업로드 시점에 모든 본문 이미지의 카드 썸네일을 만들면 미디어 관리 화면에 파생 파일이 불필요하게 늘어나고, 어떤 썸네일이 실제로 필요한지 판단하기 어려워진다. 따라서 업로드 API는 원본만 저장하고, 게시물 대표 이미지로 저장되는 이미지에 한해 카드 썸네일을 생성한다. 썸네일 파일이 지워졌거나 누락된 대표 이미지는 관리자 미디어 상세에서 다시 생성할 수 있게 두고, 상세 본문 상단 대표 이미지는 글별 옵션으로 기본 숨김 처리한다.
## 2026-06-08 v1.5.79 — 카드 썸네일은 별도 탭과 원본 연결 사용처로 관리한다
게시물 카드 썸네일은 사용자가 직접 본문에 삽입하는 원본 미디어가 아니라 목록 성능을 위해 자동 생성되는 파생 파일이다. 일반 미디어 라이브러리에 원본과 썸네일을 함께 노출하면 같은 이미지가 두 개씩 보이고, 미사용 정리 과정에서 실제 목록에 쓰는 썸네일을 삭제할 위험이 있다. 따라서 카드 썸네일을 별도 탭으로 분리하고, 사용 여부는 썸네일 URL 자체가 아니라 원본 대표 이미지 사용처에 연결해 판단한다. 원본이 대표 이미지로 쓰이지만 썸네일이 없으면 목록에서 원본을 불러오는 fallback 상태로 표시해 백필 필요 여부를 구분한다.

View File

@@ -51,7 +51,7 @@
| 파일 | 용도 |
|------|------|
| scripts/check-js-syntax.js | `npm run lint`에서 JS/MJS/CJS 파일을 `node --check`로 문법 점검 |
| scripts/backfill-post-thumbnails.js | 기존 `public/uploads/posts` 이미지의 목록 카드용 WebP 썸네일 백필 |
| scripts/backfill-post-thumbnails.js | 게시물 대표 이미지 URL 기준 목록 카드용 WebP 썸네일 백필 |
## 서버 미들웨어
@@ -96,7 +96,7 @@
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 왼쪽 사이드·게시물 본문 상단·인아티클·하단) |
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
@@ -152,7 +152,7 @@
| pages/admin/pages/index.vue | 페이지 목록, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 |
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
| 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 | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
@@ -237,9 +237,10 @@
| server/routes/admin/api/media.get.js | 관리자 미디어 목록 API |
| server/routes/admin/api/media.put.js | 관리자 미디어 파일명 변경 및 단일/복수 폴더 변경 API |
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
| server/routes/admin/api/media/thumbnail.post.js | 게시물 대표 이미지 원본의 목록 카드 썸네일 생성·재생성 API |
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 미디어 업로드 API(이미지·비디오·오디오·문서, 원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata``미분류`로 기록) |
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 미디어 원본 업로드 API(이미지·비디오·오디오·문서, 원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata``미분류`로 기록) |
| server/routes/admin/api/member-avatar.post.js | 관리자 새 회원 생성 전 썸네일 사전 업로드 API(`/uploads/members/avatars` 저장, WebP 변환·1:1 크롭) |
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
@@ -335,6 +336,7 @@
| db/migrations/043_post_export_size_and_error_detail.sql | 게시물 Export 목표 용량·실패 상세 로그 컬럼 추가 |
| db/migrations/044_site_settings_custom_code.sql | 사이트 설정 ads.txt·공통 헤더 코드·공통 푸터 코드 컬럼 추가 |
| db/migrations/045_analytics_traffic_sources.sql | 방문자 유입원·디바이스·키워드 일별 집계 테이블 추가 |
| db/migrations/054_add_post_show_featured_image.sql | 게시물 상세 제목 아래 대표 이미지 표시 여부 컬럼 추가 |
## 설정/배포

View File

@@ -89,13 +89,14 @@
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
- 공유·SEO 설명은 SEO 설명이 있으면 우선 사용하고, 없으면 게시물 요약, 요약도 없으면 본문에서 마크다운 기호를 제거한 짧은 텍스트를 사용한다.
- 홈 Latest·게시물 목록·태그 목록의 카드 설명도 동일하게 요약이 비어 있으면 본문에서 `createPostSummary`로 짧은 텍스트를 만든다. 목록용 설명은 문자열에 수동 말줄임을 붙이지 않고 `post-summary-clamp` 전용 클래스가 실제 표시 줄 끝에서 말줄임을 처리한다.
- 게시물 상세 제목 아래 대표 이미지는 관리자 글쓰기의 `본문 상단 대표 이미지` 옵션이 켜진 게시물에서만 표시한다. 대표 이미지는 목록·공유용으로는 계속 사용할 수 있으며, 본문 상단 표시 기본값은 꺼짐이다.
### 게시물 업로드 이미지 썸네일
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한 뒤, 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp` 함께 생성한다.
- 관리자 미디어 업로드 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` 하위 파일을 스캔해 누락된 카드 썸네일을 생성한다.
- 기존 대표 이미지는 `npm run images:backfill-post-thumbnails`게시물 대표 이미지 URL만 스캔해 누락된 카드 썸네일을 생성한다. 관리자 미디어의 원본 이미지 상세에서도 카드 썸네일을 개별 생성·재생성할 수 있다.
### 공개 목록·상세의 발행일 표시
@@ -289,7 +290,8 @@ components/content/
| slug | String | URL 슬러그 |
| content | Text | Markdown 콘텐츠 |
| excerpt | String | 요약 |
| featured_image | String nullable | 대표 이미지 |
| featured_image | String nullable | 목록·공유용 대표 이미지 |
| show_featured_image | Boolean | 게시물 상세 제목 아래 대표 이미지 표시 여부 |
| is_featured | Boolean | 홈 Featured 및 목록 번개 표시용 추천 글 여부 |
| seo_title | String | SEO 제목 |
| seo_description | String | SEO 설명 |
@@ -301,7 +303,7 @@ components/content/
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
> API 응답의 게시물 객체는 원본 대표 이미지 `featuredImage`, 목록용 카드 썸네일 `featuredImageThumbnail`, `isFeatured`, `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
> API 응답의 게시물 객체는 원본 대표 이미지 `featuredImage`, 상세 상단 표시 여부 `showFeaturedImage`, 목록용 카드 썸네일 `featuredImageThumbnail`, `isFeatured`, `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
> 공개 게시물 목록·상세는 `published` 상태만 기본 노출하며, `members` 상태는 VIP 이상 등급(`vip`/`admin`/`owner`) 회원에게만 노출한다. `private`와 `draft`는 공개 화면에서 노출하지 않는다.
### PostExportJobs / PostExportFiles
@@ -580,6 +582,7 @@ components/content/
- `PUT /admin/api/pages/:id` - 고정 페이지 수정
- `DELETE /admin/api/pages/:id` - 고정 페이지 삭제
- `GET /admin/api/media` - 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 `avatarOwner` 요약이 붙을 수 있음)
- `POST /admin/api/media/thumbnail` - 게시물 대표 이미지 원본 URL에서 목록 카드용 썸네일을 생성 또는 재생성
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더 `썸네일`만 허용, 일반 미디어를 `썸네일`로 옮기는 것은 거부; 썸네일 파일명 변경은 `users.avatar_url`이 해당 URL을 참조할 때만 거부)
- `DELETE /admin/api/media` - 업로드 미디어 삭제(게시물·페이지에서 사용 중이면 거부; `/members/avatars/` URL은 `users.avatar_url`이 해당 URL을 참조할 때만 거부)
- `GET /admin/api/media-folders` - 미디어 폴더 목록

View File

@@ -1,5 +1,13 @@
# 업데이트 이력
## v1.5.80
- 게시물 이미지 업로드: 본문 첨부 이미지까지 카드 썸네일을 생성하지 않도록 업로드 시점 자동 생성을 제거.
- 게시물 저장: 대표 이미지가 설정된 경우에만 목록 카드용 WebP 썸네일을 생성하도록 수정.
- 관리자 미디어: 원본 이미지 상세에서 카드 썸네일을 생성·다시 생성할 수 있는 버튼과 API 추가.
- 게시물 글쓰기: 대표 이미지를 게시물 상세 본문 상단에 표시할지 선택하는 `본문 상단 대표 이미지` 옵션 추가. 기본값은 표시 안 함.
- 공개 게시물 상세: `본문 상단 대표 이미지` 옵션이 켜진 게시물에서만 제목 아래 대표 이미지를 표시하도록 수정.
## v1.5.79
- 관리자 미디어: 게시물 카드 썸네일을 일반 미디어 목록에서 분리해 `카드 썸네일` 탭으로 표시하도록 수정.

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.79",
"version": "1.5.80",
"private": true,
"type": "module",
"imports": {

View File

@@ -25,6 +25,7 @@ const editingUrl = ref('')
const editingName = ref('')
const editingCategory = ref('')
const deletingUrl = ref('')
const regeneratingThumbnailUrl = ref('')
const selectedMediaUrl = ref('')
const selectedMediaUrls = ref([])
const lastSelectedIndex = ref(-1)
@@ -136,6 +137,16 @@ const getMediaBadgeClass = (item) => {
return 'bg-[#15171a] text-white'
}
/**
* 카드 썸네일을 생성할 수 있는 원본 이미지인지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 생성 가능 여부
*/
const canRegeneratePostThumbnail = (item) => (
item?.thumbnailStatus?.role === 'original'
&& (item.thumbnailStatus.isFallbackActive || item.thumbnailStatus.hasThumbnail)
)
/**
* 미디어 항목 종류를 반환한다.
* @param {Object} item - 미디어 항목
@@ -820,6 +831,35 @@ const deleteMedia = async (item) => {
}
}
/**
* 선택한 원본 이미지의 카드 썸네일을 다시 생성한다.
* @param {Object} item - 미디어 항목
* @returns {Promise<void>}
*/
const regeneratePostThumbnail = async (item) => {
if (!canRegeneratePostThumbnail(item)) {
return
}
regeneratingThumbnailUrl.value = item.url
try {
await $fetch('/admin/api/media/thumbnail', {
method: 'POST',
body: {
url: item.url
}
})
await refresh()
selectedMediaUrl.value = item.url
showToast('success', '카드 썸네일을 생성했습니다.')
} catch (error) {
showToast('error', error?.data?.message || '카드 썸네일을 생성하지 못했습니다.')
} finally {
regeneratingThumbnailUrl.value = ''
}
}
/**
* 선택한 미디어 중 삭제 가능한 항목을 한 번에 삭제한다.
* @returns {Promise<void>}
@@ -1304,6 +1344,15 @@ watch(filteredMediaUrls, (urls) => {
<span v-if="selectedMedia.thumbnailStatus.originalUrl" class="text-muted">
원본: {{ selectedMedia.thumbnailStatus.originalUrl }}
</span>
<button
v-if="canRegeneratePostThumbnail(selectedMedia)"
class="admin-media__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="regeneratingThumbnailUrl === selectedMedia.url"
@click="regeneratePostThumbnail(selectedMedia)"
>
{{ regeneratingThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.thumbnailStatus.hasThumbnail ? '카드 썸네일 다시 생성' : '카드 썸네일 생성') }}
</button>
</dd>
</div>
</dl>

View File

@@ -328,7 +328,7 @@ useHead(() => ({
</div>
</div>
<figure v-if="post.featuredImage" class="relative mt-2.5 w-full">
<figure v-if="post.showFeaturedImage && post.featuredImage" class="relative mt-2.5 w-full">
<img
class="aspect-video w-full rounded-[10px] object-cover bg-[var(--site-panel-strong)]"
:src="post.featuredImage"

View File

@@ -1,98 +1,30 @@
import { mkdir, readdir, stat } from 'node:fs/promises'
import { extname, join } from 'node:path'
import sharp from 'sharp'
import { listAdminPosts } from '../server/repositories/content-repository.js'
import {
POST_THUMBNAIL_HEIGHT,
POST_THUMBNAIL_QUALITY,
POST_THUMBNAIL_WIDTH,
getPostThumbnailDirectoryPath,
getPostThumbnailPathForFile,
isPostThumbnailSource
createPostThumbnailForImageUrl,
getExistingPostThumbnailUrl,
getPostThumbnailUrl
} 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)
const posts = await listAdminPosts()
const featuredImageUrls = [...new Set(posts
.map((post) => post.featuredImage)
.filter((url) => getPostThumbnailUrl(url)))]
let createdCount = 0
let skippedCount = 0
for (const imagePath of images) {
if (!isPostThumbnailSource('', imagePath) || extname(imagePath).toLowerCase() === '.gif') {
for (const imageUrl of featuredImageUrls) {
if (getExistingPostThumbnailUrl(imageUrl)) {
skippedCount += 1
continue
}
if (await createPostThumbnail(imagePath)) {
const thumbnailUrl = await createPostThumbnailForImageUrl(imageUrl)
if (thumbnailUrl) {
createdCount += 1
} else {
skippedCount += 1
}
}
process.stdout.write(`게시물 카드 썸네일 생성 완료: 생성 ${createdCount}개, 건너뜀 ${skippedCount}\n`)
process.stdout.write(`게시물 대표 이미지 카드 썸네일 생성 완료: 생성 ${createdCount}개, 건너뜀 ${skippedCount}\n`)

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
}