From 3a4e39332a94c0ade10428d92d37ef3e79525b14 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 2 Jul 2026 10:09:39 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BB=A4?= =?UTF-8?q?=EB=B2=84=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 1 + docs/changelog.md | 6 + docs/deploy.md | 4 + docs/history.md | 4 + docs/map.md | 6 +- docs/spec.md | 3 +- docs/update.md | 8 ++ lib/post-card-thumbnail-url.js | 17 +++ package-lock.json | 4 +- package.json | 3 +- pages/admin/media/index.vue | 90 ++++++++++++++- pages/admin/navigation/index.vue | 2 +- pages/index.vue | 2 +- ...ckfill-page-cover-thumbnails-production.sh | 40 +++++++ scripts/backfill-page-cover-thumbnails.js | 107 ++++++++++++++++++ server/repositories/content-repository.js | 9 ++ .../routes/admin/api/media/thumbnail.post.js | 13 ++- server/routes/uploads/[...path].get.js | 2 +- server/utils/media-library.js | 93 ++++++++++++++- server/utils/post-thumbnail-image.js | 74 ++++++++++++ 20 files changed, 463 insertions(+), 25 deletions(-) create mode 100755 scripts/backfill-page-cover-thumbnails-production.sh create mode 100644 scripts/backfill-page-cover-thumbnails.js diff --git a/Dockerfile b/Dockerfile index 91648bc..41e55ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ ENV PORT=3000 COPY --from=builder /app/.output ./.output COPY --from=builder /app/scripts/backfill-post-thumbnails.js ./.output/server/scripts/backfill-post-thumbnails.js COPY --from=builder /app/scripts/backfill-post-body-thumbnails.js ./.output/server/scripts/backfill-post-body-thumbnails.js +COPY --from=builder /app/scripts/backfill-page-cover-thumbnails.js ./.output/server/scripts/backfill-page-cover-thumbnails.js COPY --from=builder /app/server/utils/post-thumbnail-image.js ./.output/server/server/utils/post-thumbnail-image.js COPY --from=builder /app/lib/post-card-thumbnail-url.js ./.output/server/lib/post-card-thumbnail-url.js diff --git a/docs/changelog.md b/docs/changelog.md index e7c8ccf..eefb6fb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,11 @@ # 업데이트 요약 +## v1.10.12 + +- 홈 Pages 진열장 커버 이미지도 3:4 WebP 썸네일을 자동 생성해 원본 대신 우선 사용한다. +- 기존 페이지 커버 이미지를 한 번에 썸네일로 만드는 백필 명령을 추가했다. +- 관리자 미디어에서 Pages 커버 썸네일이 미사용 파일로 섞이지 않도록 분류와 사용처를 연결했다. + ## v1.10.8 - 게시물 이미지 크게 보기에서 원본 대신 2560px 라이트박스용 WebP 이미지를 우선 사용하도록 바꿨다. diff --git a/docs/deploy.md b/docs/deploy.md index c20b269..002194e 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -404,12 +404,16 @@ docker compose --env-file .env.production restart sori-studio-db |------|------| | `npm run images:backfill-post-thumbnails` | 로컬 Node/npm 환경에서 게시물 대표 이미지용 카드 썸네일(`thumbs/*-card.webp`) 일괄 생성 | | `npm run images:backfill-post-body-thumbnails` | 로컬 Node/npm 환경에서 게시물 본문 표시용 WebP 이미지(`thumbs/*-body.webp`, `thumbs/*-body-720.webp`, `thumbs/*-lightbox.webp`) 일괄 생성 | +| `npm run images:backfill-page-cover-thumbnails` | 로컬 Node/npm 환경에서 홈 Pages 진열장 커버용 WebP 이미지(`thumbs/*-page-cover.webp`) 일괄 생성 | | `sh scripts/backfill-post-thumbnails-production.sh` | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 카드 썸네일 일괄 생성 | | `sh scripts/backfill-post-body-thumbnails-production.sh` | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 본문 표시용 WebP 이미지 일괄 생성 | +| `sh scripts/backfill-page-cover-thumbnails-production.sh` | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 Pages 커버 썸네일 일괄 생성 | | `npm run navigation:refresh-recommended-favicons` | 추천 사이트 파비콘 캐시를 수동으로 전체 갱신 | 본문 표시용 WebP 이미지(`thumbs/*-body.webp`, `thumbs/*-body-720.webp`, `thumbs/*-lightbox.webp`)는 게시물 저장 시 생성된다. 기존 게시물의 과거 이미지는 로컬에서는 `npm run images:backfill-post-body-thumbnails`, NAS 운영에서는 `sh scripts/backfill-post-body-thumbnails-production.sh`로 일괄 생성할 수 있다. production 래퍼는 `docker compose up -d sori-studio` 후 실행 중인 앱 컨테이너 안의 Node로 백필 스크립트를 실행하므로 NAS 호스트에 npm이 없어도 된다. +Pages 진열장 커버용 WebP 이미지(`thumbs/*-page-cover.webp`, 450×600)는 페이지 저장 시 생성된다. 기존 홈 노출 페이지의 과거 이미지는 로컬에서는 `npm run images:backfill-page-cover-thumbnails`, NAS 운영에서는 `sh scripts/backfill-page-cover-thumbnails-production.sh`로 일괄 생성할 수 있다. + Homepage 위젯 연동은 `docs/homepage-widget.md`를 참고한다. Docker에서 Homepage가 같은 네트워크에 있으면 `http://sori-studio:3000/api/homepage-widget`을 사용한다. --- diff --git a/docs/history.md b/docs/history.md index 2211bfd..6ad7f62 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-07-02 v1.10.12 — Pages 커버는 별도 3:4 썸네일을 사용한다 + +홈 Pages 진열장은 섹션별로 여러 페이지 커버가 동시에 노출될 수 있어 원본 이미지를 그대로 사용하면 첫 화면 전송량이 커진다. 게시물 카드 썸네일은 16:9라 Pages 커버의 3:4 비율과 맞지 않으므로, 페이지 커버 전용 `thumbs/*-page-cover.webp` 파생 이미지를 450×600으로 생성한다. 원본은 관리자 다운로드와 보관용으로 유지하고, 공개 홈과 페이지 순서 모달은 생성된 커버 썸네일을 우선 사용한다. 자동 생성 파일은 관리자 미디어의 원본 라이브러리에서 분리해 실수로 미사용 삭제되는 상황을 막는다. + ## 2026-07-01 v1.10.11 — 업로드 파일 캐시는 원본과 파생 이미지를 다르게 둔다 업로드 파일은 대부분 고유 URL로 저장되어 같은 URL의 파일 내용이 바뀌지 않는다. 이런 원본·시스템·회원 썸네일 파일은 장기 immutable 캐시를 적용해 이미지 많은 페이지의 반복 요청 비용을 줄인다. 반면 게시물 `thumbs` 파생 이미지는 관리자 미디어에서 같은 URL로 다시 생성할 수 있으므로 1년 immutable을 적용하면 수정 결과가 오래 보이지 않을 수 있다. 따라서 파생 이미지는 1시간 캐시와 `stale-while-revalidate`를 사용하고, 모든 업로드 응답에 `ETag`와 `Last-Modified`를 제공해 재검증 시 304로 빠르게 응답하도록 정리한다. diff --git a/docs/map.md b/docs/map.md index c969310..422ceae 100644 --- a/docs/map.md +++ b/docs/map.md @@ -26,7 +26,7 @@ | 파일 | 용도 | |------|------| | lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·1회 파일 수/총량 제한 정규화·용량 문구(서버·클라이언트 공용) | -| lib/post-card-thumbnail-url.js | 게시물 카드 썸네일 `thumbs/*-card.webp`와 본문 표시용 1600px/720px·라이트박스용 2560px 파생 이미지 URL 판별(대표 이미지·본문 이미지·갤러리 선택 및 서버 검증 공용) | +| lib/post-card-thumbnail-url.js | 게시물 카드 썸네일 `thumbs/*-card.webp`, Pages 커버 썸네일 `thumbs/*-page-cover.webp`, 본문 표시용 1600px/720px·라이트박스용 2560px 파생 이미지 URL 판별(대표 이미지·본문 이미지·갤러리·Pages 커버 선택 및 서버 검증 공용) | | lib/external-favicon-url.js | 외부 URL origin 기준 Google `gstatic.com/faviconV2` 파비콘 URL 생성(내부 경로는 빈 문자열) | | lib/navigation-url.js | 네비게이션 URL 접두사 분리·합성·정규화(`https://` 자동 보정, `/` 내부 경로) | | lib/recommended-site-icon-url.js | 추천 사이트 아이콘 URL 우선순위(수동 썸네일 > 서버 캐시 > 외부 프록시) | @@ -66,8 +66,10 @@ | scripts/check-js-syntax.js | `npm run lint`에서 JS/MJS/CJS 파일을 `node --check`로 문법 점검 | | scripts/backfill-post-thumbnails.js | 게시물 대표 이미지 URL 기준 목록 카드용 WebP 썸네일 백필 | | scripts/backfill-post-body-thumbnails.js | 게시물 본문 이미지 URL 기준 본문 표시용 WebP 썸네일 백필 | +| scripts/backfill-page-cover-thumbnails.js | 홈 Pages 진열장 커버 이미지 URL 기준 3:4 WebP 썸네일 백필 | | scripts/backfill-post-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 카드 썸네일 백필 실행 | | scripts/backfill-post-body-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 본문 표시용 WebP 백필 실행 | +| scripts/backfill-page-cover-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 Pages 커버 썸네일 백필 실행 | ## DB 마이그레이션 @@ -95,7 +97,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 | 게시물 업로드 이미지의 카드 썸네일·본문 표시용 1600px/720px WebP·라이트박스용 2560px WebP 이미지 URL·디스크 경로·생성 가능 여부·본문 이미지 추출 규칙 | +| server/utils/post-thumbnail-image.js | 게시물 업로드 이미지의 카드 썸네일·Pages 3:4 커버 썸네일·본문 표시용 1600px/720px WebP·라이트박스용 2560px WebP 이미지 URL·디스크 경로·생성 가능 여부·본문 이미지 추출 규칙 | | server/utils/sitemap.js | 공개 sitemap XML·robots.txt 생성 | | server/utils/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 | diff --git a/docs/spec.md b/docs/spec.md index d5a514e..e505a21 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -112,6 +112,7 @@ - 공개 게시물 응답은 원본 대표 이미지 `featuredImage`를 유지하고, 대응 썸네일 파일이 실제로 존재할 때만 `featuredImageThumbnail`을 추가한다. - 기존 대표 이미지는 로컬에서 `npm run images:backfill-post-thumbnails`, NAS 운영에서 `sh scripts/backfill-post-thumbnails-production.sh`로 게시물 대표 이미지 URL만 스캔해 누락된 카드 썸네일을 생성한다. 관리자 미디어의 원본 이미지 상세에서도 카드 썸네일을 개별 생성·재생성할 수 있다. - 기존 본문 이미지는 로컬에서 `npm run images:backfill-post-body-thumbnails`, NAS 운영에서 `sh scripts/backfill-post-body-thumbnails-production.sh`로 전체 게시물 본문 URL을 스캔해 누락된 본문 표시용 WebP, 작은 본문 표시용 WebP, 라이트박스용 WebP를 생성한다. +- 기존 Pages 진열장 커버 이미지는 로컬에서 `npm run images:backfill-page-cover-thumbnails`, NAS 운영에서 `sh scripts/backfill-page-cover-thumbnails-production.sh`로 홈 노출 페이지의 누락된 3:4 WebP 썸네일을 생성한다. ### 공개 목록·상세의 발행일 표시 @@ -125,7 +126,7 @@ - About, Projects, Links, Contact, 서비스 소개 페이지 등 고정 콘텐츠에 사용 - 기본 게시물 목록에는 노출하지 않음 - 페이지 설정의 `홈에 표시`가 켜진 공개 페이지는 홈 화면의 Pages 진열장 영역에 노출한다. 진열장은 `섹션명` 기준으로 여러 선반을 만들고, 각 페이지는 도서/게임 라이브러리처럼 세로형 커버 카드로 표시한다. 각 선반은 Featured와 같은 이전·다음 버튼과 드래그 슬라이드·스냅 이동을 지원하며, 선반별로 독립 동작한다. 카드 폭은 데스크톱 기준 약 164px로 여러 페이지가 있을 때 가로 탐색이 명확히 생기도록 한다. 섹션별 표시 개수는 사이트 설정 POST 설정의 `pageSectionItemLimit` 값을 따른다. 기본값은 12개다. -- Pages 진열장 커버는 3:4 세로 비율을 기준으로 표시한다. 제작 권장 크기는 `900×1200px`이며, 고해상도 원본을 준비할 때는 `1200×1600px`을 권장한다. 최소 권장 크기는 `600×800px`이다. +- Pages 진열장 커버는 3:4 세로 비율을 기준으로 표시한다. 제작 권장 크기는 `900×1200px`이며, 고해상도 원본을 준비할 때는 `1200×1600px`을 권장한다. 최소 권장 크기는 `600×800px`이다. 페이지 저장 시 원본 커버 이미지에서 홈 진열장용 WebP 썸네일(`thumbs/*-page-cover.webp`, 450×600, cover)을 생성하고, 공개 페이지 목록 응답은 `listingImageThumbnail`이 있으면 원본 `featuredImage`보다 우선 사용한다. - 페이지 섹션 생성·이름 변경·삭제·표시 순서는 관리자 네비게이션 관리의 `페이지 섹션` 탭에서 다룬다. 섹션 추가는 모달에서 이름을 입력해 생성하고, 이름 변경과 섹션 표시 순서는 탭 안의 `페이지 섹션 저장`으로 한 번에 저장한다. 페이지 작성·수정 화면에서는 등록된 섹션을 검색형 입력으로 선택한다. - 섹션 내부 페이지 표시 순서는 관리자 네비게이션 관리의 `페이지 섹션` 탭에서 섹션별 `페이지 순서` 모달로 조정하며, 페이지 작성·수정 화면에서는 직접 입력하지 않는다. 순서 모달은 페이지 썸네일 카드형 가로 목록으로 표시하고, 추천 페이지를 먼저 보여 주며, 추천 페이지가 비추천 페이지 뒤로 드롭되면 즉시 추천 우선 순서로 보정한다. 페이지를 해당 섹션에서 바로 제거할 수 있다. - `추천으로 강조`된 페이지는 같은 섹션 안에서 먼저 정렬하며, 홈 Pages 진열장 카드 왼쪽 위에 SVG 아이콘 배지로 표시한다. diff --git a/docs/update.md b/docs/update.md index 87a49c4..112197a 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,13 @@ # 업데이트 이력 +## v1.10.12 + +- 홈 Pages 진열장 커버 이미지용 3:4 WebP 썸네일(`thumbs/*-page-cover.webp`, 450×600) 생성 추가. +- 페이지 생성·수정 저장 시 목록 썸네일 원본에서 페이지 커버 썸네일을 자동 생성하도록 수정. +- 공개 홈 Pages 진열장과 관리자 페이지 순서 모달이 `listingImageThumbnail`을 우선 사용하도록 수정. +- 관리자 미디어에서 페이지 커버 썸네일을 카드 썸네일 탭에 분류하고 원본 페이지 이미지 사용처와 연결해 미사용 삭제 대상에서 빠지도록 수정. +- 기존 페이지 커버 이미지 백필 명령(`npm run images:backfill-page-cover-thumbnails`, `sh scripts/backfill-page-cover-thumbnails-production.sh`) 추가. + ## v1.10.11 - `/uploads/**` 런타임 파일 제공 API에 `ETag` 조건부 요청 304 응답 추가. diff --git a/lib/post-card-thumbnail-url.js b/lib/post-card-thumbnail-url.js index d52a114..ce8bd3b 100644 --- a/lib/post-card-thumbnail-url.js +++ b/lib/post-card-thumbnail-url.js @@ -1,4 +1,5 @@ const postCardThumbnailUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-card\.webp$/i +const pageCoverThumbnailUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-page-cover\.webp$/i const postBodyThumbnailUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-(?:body(?:-720)?|lightbox)\.webp$/i const postUploadImageUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^/?#]+\.(?:jpe?g|png|webp)$/i @@ -33,6 +34,15 @@ export const isPostCardThumbnailUrl = (imageUrl) => Boolean( imageUrl && postCardThumbnailUrlPattern.test(String(imageUrl)) ) +/** + * 페이지 커버 썸네일 URL인지 확인한다. + * @param {string|null|undefined} imageUrl - 검사할 이미지 URL + * @returns {boolean} 페이지 커버 썸네일 여부 + */ +export const isPageCoverThumbnailUrl = (imageUrl) => Boolean( + imageUrl && pageCoverThumbnailUrlPattern.test(String(imageUrl)) +) + /** * 게시물 본문 표시용 파생 이미지 URL인지 확인한다. * @param {string|null|undefined} imageUrl - 검사할 이미지 URL @@ -62,3 +72,10 @@ export const getPostBodyPreviewThumbnailUrl = (imageUrl) => getPostDerivativeIma * @returns {string} 라이트박스용 파생 이미지 URL */ export const getPostLightboxThumbnailUrl = (imageUrl) => getPostDerivativeImageUrl(imageUrl, 'lightbox') + +/** + * 페이지 커버 원본 이미지 URL에 대응하는 홈 진열장 썸네일 URL을 만든다. + * @param {string|null|undefined} imageUrl - 원본 이미지 URL + * @returns {string} 페이지 커버 썸네일 URL + */ +export const getPageCoverThumbnailUrl = (imageUrl) => getPostDerivativeImageUrl(imageUrl, 'page-cover') diff --git a/package-lock.json b/package-lock.json index 852eea1..28867ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.10.11", + "version": "1.10.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.10.11", + "version": "1.10.12", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index ad86c22..6bce167 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.11", + "version": "1.10.12", "private": true, "type": "module", "imports": { @@ -24,6 +24,7 @@ "db:migrate:prod:baseline": "sh scripts/migrate-production-db.sh baseline", "images:backfill-post-thumbnails": "node scripts/backfill-post-thumbnails.js", "images:backfill-post-body-thumbnails": "node scripts/backfill-post-body-thumbnails.js", + "images:backfill-page-cover-thumbnails": "node scripts/backfill-page-cover-thumbnails.js", "navigation:refresh-recommended-favicons": "node scripts/refresh-recommended-favicons.js", "postinstall": "nuxt prepare" }, diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index 67b394a..481c02b 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -8,6 +8,7 @@ import { normalizeUploadMaxTotalSize } from '~/lib/upload-size-limit.js' import { + isPageCoverThumbnailUrl, isPostBodyThumbnailUrl, isPostCardThumbnailUrl } from '~/lib/post-card-thumbnail-url.js' @@ -90,6 +91,13 @@ const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avat */ const isPostCardThumbnailDiskItem = (item) => isPostCardThumbnailUrl(item?.url) +/** + * 페이지 커버 썸네일 디스크 경로 여부 + * @param {Object} item - 미디어 항목 + * @returns {boolean} 페이지 커버 썸네일이면 true + */ +const isPageCoverThumbnailDiskItem = (item) => isPageCoverThumbnailUrl(item?.url) + /** * 게시물 본문 표시용 이미지 디스크 경로 여부 * @param {Object} item - 미디어 항목 @@ -121,7 +129,7 @@ const getPostBodyThumbnailResolutionLabel = (item) => { * @param {Object} item - 미디어 항목 * @returns {boolean} 시스템 관리 항목이면 true */ -const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item) || isPostBodyThumbnailDiskItem(item) +const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item) || isPageCoverThumbnailDiskItem(item) || isPostBodyThumbnailDiskItem(item) /** * 파일명 변경·삭제·드래그 이동이 제한되는지 여부 @@ -135,7 +143,7 @@ const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item * @param {Object} item - 미디어 항목 * @returns {boolean} 변경 가능 여부 */ -const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item) +const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item) && !isPageCoverThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item) /** * 미디어 논리 폴더를 변경할 수 있는지 확인한다. @@ -167,6 +175,10 @@ const getMediaBadgeLabel = (item) => { return '카드' } + if (item?.thumbnailStatus?.role === 'pageCover') { + return '페이지' + } + if (item?.bodyThumbnailStatus?.role === 'body') { return '본문' } @@ -196,6 +208,10 @@ const getMediaBadgeClass = (item) => { return 'bg-sky-700 text-white' } + if (item?.thumbnailStatus?.role === 'pageCover') { + return 'bg-cyan-800 text-white' + } + if (item?.bodyThumbnailStatus?.role === 'body') { return 'bg-violet-700 text-white' } @@ -226,6 +242,18 @@ const getMediaUsageBadgeCount = (item) => { */ const canRegeneratePostThumbnail = (item) => ( item?.thumbnailStatus?.role === 'original' + && item?.thumbnailStatus?.variant !== 'pageCover' + && (item.thumbnailStatus.isFallbackActive || item.thumbnailStatus.hasThumbnail) +) + +/** + * 페이지 커버 썸네일을 생성할 수 있는 원본 이미지인지 확인한다. + * @param {Object} item - 미디어 항목 + * @returns {boolean} 생성 가능 여부 + */ +const canRegeneratePageCoverThumbnail = (item) => ( + item?.thumbnailStatus?.role === 'original' + && item?.thumbnailStatus?.variant === 'pageCover' && (item.thumbnailStatus.isFallbackActive || item.thumbnailStatus.hasThumbnail) ) @@ -246,9 +274,9 @@ const canRegeneratePostBodyThumbnail = (item) => ( */ const getMediaItemKind = (item) => item?.kind || 'image' -const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item) && !isPostCardThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item))) +const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item) && !isPostCardThumbnailDiskItem(item) && !isPageCoverThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item))) -const postThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostCardThumbnailDiskItem(item))) +const postThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostCardThumbnailDiskItem(item) || isPageCoverThumbnailDiskItem(item))) const postBodyThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostBodyThumbnailDiskItem(item))) @@ -298,7 +326,7 @@ const activeSystemMediaTitle = computed(() => { const activeSystemMediaHint = computed(() => { if (activeTab.value === 'postThumbnails') { - return '게시물 목록 카드에서 쓰는 자동 생성 이미지입니다. 원본 대표 이미지가 사용 중이고 이 썸네일 파일이 있으면 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 썸네일은 사용 중으로 표시되며, 썸네일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.' + return '게시물 목록 카드와 홈 Pages 진열장에서 쓰는 자동 생성 이미지입니다. 원본 이미지가 사용 중이고 이 썸네일 파일이 있으면 공개 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 썸네일은 사용 중으로 표시되며, 썸네일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.' } if (activeTab.value === 'postBodyThumbnails') { @@ -1020,6 +1048,36 @@ const regeneratePostThumbnail = async (item) => { } } +/** + * 선택한 원본 이미지의 페이지 커버 썸네일을 다시 생성한다. + * @param {Object} item - 미디어 항목 + * @returns {Promise} + */ +const regeneratePageCoverThumbnail = async (item) => { + if (!canRegeneratePageCoverThumbnail(item)) { + return + } + + regeneratingThumbnailUrl.value = item.url + + try { + await $fetch('/admin/api/media/thumbnail', { + method: 'POST', + body: { + url: item.url, + kind: 'pageCover' + } + }) + await refresh() + selectedMediaUrl.value = item.url + showToast('success', '페이지 커버 썸네일을 생성했습니다.') + } catch (error) { + showToast('error', error?.data?.message || '페이지 커버 썸네일을 생성하지 못했습니다.') + } finally { + regeneratingThumbnailUrl.value = '' + } +} + /** * 선택한 원본 이미지의 본문 표시용 이미지를 다시 생성한다. * @param {Object} item - 미디어 항목 @@ -1534,18 +1592,29 @@ watch(filteredMediaUrls, (urls) => {
{{ formatFileSize(selectedMedia.size) }}
-
카드 썸네일
+
+ {{ selectedMedia.thumbnailStatus.variant === 'pageCover' || selectedMedia.thumbnailStatus.role === 'pageCover' ? '페이지 커버 썸네일' : '카드 썸네일' }} +
자동 생성된 목록 카드용 썸네일입니다. + + 자동 생성된 Pages 진열장용 커버 썸네일입니다. +