페이지 커버 썸네일 최적화
This commit is contained in:
@@ -24,6 +24,7 @@ ENV PORT=3000
|
|||||||
COPY --from=builder /app/.output ./.output
|
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-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-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/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
|
COPY --from=builder /app/lib/post-card-thumbnail-url.js ./.output/server/lib/post-card-thumbnail-url.js
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.10.12
|
||||||
|
|
||||||
|
- 홈 Pages 진열장 커버 이미지도 3:4 WebP 썸네일을 자동 생성해 원본 대신 우선 사용한다.
|
||||||
|
- 기존 페이지 커버 이미지를 한 번에 썸네일로 만드는 백필 명령을 추가했다.
|
||||||
|
- 관리자 미디어에서 Pages 커버 썸네일이 미사용 파일로 섞이지 않도록 분류와 사용처를 연결했다.
|
||||||
|
|
||||||
## v1.10.8
|
## v1.10.8
|
||||||
|
|
||||||
- 게시물 이미지 크게 보기에서 원본 대신 2560px 라이트박스용 WebP 이미지를 우선 사용하도록 바꿨다.
|
- 게시물 이미지 크게 보기에서 원본 대신 2560px 라이트박스용 WebP 이미지를 우선 사용하도록 바꿨다.
|
||||||
|
|||||||
@@ -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-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-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-thumbnails-production.sh` | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 카드 썸네일 일괄 생성 |
|
||||||
| `sh scripts/backfill-post-body-thumbnails-production.sh` | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 본문 표시용 WebP 이미지 일괄 생성 |
|
| `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` | 추천 사이트 파비콘 캐시를 수동으로 전체 갱신 |
|
| `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이 없어도 된다.
|
본문 표시용 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`을 사용한다.
|
Homepage 위젯 연동은 `docs/homepage-widget.md`를 참고한다. Docker에서 Homepage가 같은 네트워크에 있으면 `http://sori-studio:3000/api/homepage-widget`을 사용한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 — 업로드 파일 캐시는 원본과 파생 이미지를 다르게 둔다
|
## 2026-07-01 v1.10.11 — 업로드 파일 캐시는 원본과 파생 이미지를 다르게 둔다
|
||||||
|
|
||||||
업로드 파일은 대부분 고유 URL로 저장되어 같은 URL의 파일 내용이 바뀌지 않는다. 이런 원본·시스템·회원 썸네일 파일은 장기 immutable 캐시를 적용해 이미지 많은 페이지의 반복 요청 비용을 줄인다. 반면 게시물 `thumbs` 파생 이미지는 관리자 미디어에서 같은 URL로 다시 생성할 수 있으므로 1년 immutable을 적용하면 수정 결과가 오래 보이지 않을 수 있다. 따라서 파생 이미지는 1시간 캐시와 `stale-while-revalidate`를 사용하고, 모든 업로드 응답에 `ETag`와 `Last-Modified`를 제공해 재검증 시 304로 빠르게 응답하도록 정리한다.
|
업로드 파일은 대부분 고유 URL로 저장되어 같은 URL의 파일 내용이 바뀌지 않는다. 이런 원본·시스템·회원 썸네일 파일은 장기 immutable 캐시를 적용해 이미지 많은 페이지의 반복 요청 비용을 줄인다. 반면 게시물 `thumbs` 파생 이미지는 관리자 미디어에서 같은 URL로 다시 생성할 수 있으므로 1년 immutable을 적용하면 수정 결과가 오래 보이지 않을 수 있다. 따라서 파생 이미지는 1시간 캐시와 `stale-while-revalidate`를 사용하고, 모든 업로드 응답에 `ETag`와 `Last-Modified`를 제공해 재검증 시 304로 빠르게 응답하도록 정리한다.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
| 파일 | 용도 |
|
| 파일 | 용도 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·1회 파일 수/총량 제한 정규화·용량 문구(서버·클라이언트 공용) |
|
| 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/external-favicon-url.js | 외부 URL origin 기준 Google `gstatic.com/faviconV2` 파비콘 URL 생성(내부 경로는 빈 문자열) |
|
||||||
| lib/navigation-url.js | 네비게이션 URL 접두사 분리·합성·정규화(`https://` 자동 보정, `/` 내부 경로) |
|
| lib/navigation-url.js | 네비게이션 URL 접두사 분리·합성·정규화(`https://` 자동 보정, `/` 내부 경로) |
|
||||||
| lib/recommended-site-icon-url.js | 추천 사이트 아이콘 URL 우선순위(수동 썸네일 > 서버 캐시 > 외부 프록시) |
|
| 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/check-js-syntax.js | `npm run lint`에서 JS/MJS/CJS 파일을 `node --check`로 문법 점검 |
|
||||||
| scripts/backfill-post-thumbnails.js | 게시물 대표 이미지 URL 기준 목록 카드용 WebP 썸네일 백필 |
|
| scripts/backfill-post-thumbnails.js | 게시물 대표 이미지 URL 기준 목록 카드용 WebP 썸네일 백필 |
|
||||||
| scripts/backfill-post-body-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-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 카드 썸네일 백필 실행 |
|
||||||
| scripts/backfill-post-body-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 본문 표시용 WebP 백필 실행 |
|
| scripts/backfill-post-body-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 본문 표시용 WebP 백필 실행 |
|
||||||
|
| scripts/backfill-page-cover-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 Pages 커버 썸네일 백필 실행 |
|
||||||
|
|
||||||
## DB 마이그레이션
|
## DB 마이그레이션
|
||||||
|
|
||||||
@@ -95,7 +97,7 @@
|
|||||||
| server/routes/feed.xml.get.js | 공개 RSS 2.0 피드 별칭(`/feed.xml`) |
|
| server/routes/feed.xml.get.js | 공개 RSS 2.0 피드 별칭(`/feed.xml`) |
|
||||||
| server/routes/rss.get.js | 공개 RSS 2.0 피드 별칭(`/rss`) |
|
| server/routes/rss.get.js | 공개 RSS 2.0 피드 별칭(`/rss`) |
|
||||||
| server/plugins/site-custom-code.js | 공개 Nuxt HTML 응답에 사이트 설정 헤더·푸터 코드 삽입(`/admin`, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 제외) |
|
| 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/sitemap.js | 공개 sitemap XML·robots.txt 생성 |
|
||||||
| server/utils/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 |
|
| server/utils/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 |
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
- 공개 게시물 응답은 원본 대표 이미지 `featuredImage`를 유지하고, 대응 썸네일 파일이 실제로 존재할 때만 `featuredImageThumbnail`을 추가한다.
|
- 공개 게시물 응답은 원본 대표 이미지 `featuredImage`를 유지하고, 대응 썸네일 파일이 실제로 존재할 때만 `featuredImageThumbnail`을 추가한다.
|
||||||
- 기존 대표 이미지는 로컬에서 `npm run images:backfill-post-thumbnails`, NAS 운영에서 `sh scripts/backfill-post-thumbnails-production.sh`로 게시물 대표 이미지 URL만 스캔해 누락된 카드 썸네일을 생성한다. 관리자 미디어의 원본 이미지 상세에서도 카드 썸네일을 개별 생성·재생성할 수 있다.
|
- 기존 대표 이미지는 로컬에서 `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를 생성한다.
|
- 기존 본문 이미지는 로컬에서 `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, 서비스 소개 페이지 등 고정 콘텐츠에 사용
|
- About, Projects, Links, Contact, 서비스 소개 페이지 등 고정 콘텐츠에 사용
|
||||||
- 기본 게시물 목록에는 노출하지 않음
|
- 기본 게시물 목록에는 노출하지 않음
|
||||||
- 페이지 설정의 `홈에 표시`가 켜진 공개 페이지는 홈 화면의 Pages 진열장 영역에 노출한다. 진열장은 `섹션명` 기준으로 여러 선반을 만들고, 각 페이지는 도서/게임 라이브러리처럼 세로형 커버 카드로 표시한다. 각 선반은 Featured와 같은 이전·다음 버튼과 드래그 슬라이드·스냅 이동을 지원하며, 선반별로 독립 동작한다. 카드 폭은 데스크톱 기준 약 164px로 여러 페이지가 있을 때 가로 탐색이 명확히 생기도록 한다. 섹션별 표시 개수는 사이트 설정 POST 설정의 `pageSectionItemLimit` 값을 따른다. 기본값은 12개다.
|
- 페이지 설정의 `홈에 표시`가 켜진 공개 페이지는 홈 화면의 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 아이콘 배지로 표시한다.
|
- `추천으로 강조`된 페이지는 같은 섹션 안에서 먼저 정렬하며, 홈 Pages 진열장 카드 왼쪽 위에 SVG 아이콘 배지로 표시한다.
|
||||||
|
|||||||
@@ -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
|
## v1.10.11
|
||||||
|
|
||||||
- `/uploads/**` 런타임 파일 제공 API에 `ETag` 조건부 요청 304 응답 추가.
|
- `/uploads/**` 런타임 파일 제공 API에 `ETag` 조건부 요청 304 응답 추가.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const postCardThumbnailUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-card\.webp$/i
|
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 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
|
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))
|
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인지 확인한다.
|
* 게시물 본문 표시용 파생 이미지 URL인지 확인한다.
|
||||||
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
|
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
|
||||||
@@ -62,3 +72,10 @@ export const getPostBodyPreviewThumbnailUrl = (imageUrl) => getPostDerivativeIma
|
|||||||
* @returns {string} 라이트박스용 파생 이미지 URL
|
* @returns {string} 라이트박스용 파생 이미지 URL
|
||||||
*/
|
*/
|
||||||
export const getPostLightboxThumbnailUrl = (imageUrl) => getPostDerivativeImageUrl(imageUrl, 'lightbox')
|
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')
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.11",
|
"version": "1.10.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.11",
|
"version": "1.10.12",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.11",
|
"version": "1.10.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"db:migrate:prod:baseline": "sh scripts/migrate-production-db.sh baseline",
|
"db:migrate:prod:baseline": "sh scripts/migrate-production-db.sh baseline",
|
||||||
"images:backfill-post-thumbnails": "node scripts/backfill-post-thumbnails.js",
|
"images:backfill-post-thumbnails": "node scripts/backfill-post-thumbnails.js",
|
||||||
"images:backfill-post-body-thumbnails": "node scripts/backfill-post-body-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",
|
"navigation:refresh-recommended-favicons": "node scripts/refresh-recommended-favicons.js",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
normalizeUploadMaxTotalSize
|
normalizeUploadMaxTotalSize
|
||||||
} from '~/lib/upload-size-limit.js'
|
} from '~/lib/upload-size-limit.js'
|
||||||
import {
|
import {
|
||||||
|
isPageCoverThumbnailUrl,
|
||||||
isPostBodyThumbnailUrl,
|
isPostBodyThumbnailUrl,
|
||||||
isPostCardThumbnailUrl
|
isPostCardThumbnailUrl
|
||||||
} from '~/lib/post-card-thumbnail-url.js'
|
} 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)
|
const isPostCardThumbnailDiskItem = (item) => isPostCardThumbnailUrl(item?.url)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 커버 썸네일 디스크 경로 여부
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @returns {boolean} 페이지 커버 썸네일이면 true
|
||||||
|
*/
|
||||||
|
const isPageCoverThumbnailDiskItem = (item) => isPageCoverThumbnailUrl(item?.url)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 본문 표시용 이미지 디스크 경로 여부
|
* 게시물 본문 표시용 이미지 디스크 경로 여부
|
||||||
* @param {Object} item - 미디어 항목
|
* @param {Object} item - 미디어 항목
|
||||||
@@ -121,7 +129,7 @@ const getPostBodyThumbnailResolutionLabel = (item) => {
|
|||||||
* @param {Object} item - 미디어 항목
|
* @param {Object} item - 미디어 항목
|
||||||
* @returns {boolean} 시스템 관리 항목이면 true
|
* @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 - 미디어 항목
|
* @param {Object} item - 미디어 항목
|
||||||
* @returns {boolean} 변경 가능 여부
|
* @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 '카드'
|
return '카드'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item?.thumbnailStatus?.role === 'pageCover') {
|
||||||
|
return '페이지'
|
||||||
|
}
|
||||||
|
|
||||||
if (item?.bodyThumbnailStatus?.role === 'body') {
|
if (item?.bodyThumbnailStatus?.role === 'body') {
|
||||||
return '본문'
|
return '본문'
|
||||||
}
|
}
|
||||||
@@ -196,6 +208,10 @@ const getMediaBadgeClass = (item) => {
|
|||||||
return 'bg-sky-700 text-white'
|
return 'bg-sky-700 text-white'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item?.thumbnailStatus?.role === 'pageCover') {
|
||||||
|
return 'bg-cyan-800 text-white'
|
||||||
|
}
|
||||||
|
|
||||||
if (item?.bodyThumbnailStatus?.role === 'body') {
|
if (item?.bodyThumbnailStatus?.role === 'body') {
|
||||||
return 'bg-violet-700 text-white'
|
return 'bg-violet-700 text-white'
|
||||||
}
|
}
|
||||||
@@ -226,6 +242,18 @@ const getMediaUsageBadgeCount = (item) => {
|
|||||||
*/
|
*/
|
||||||
const canRegeneratePostThumbnail = (item) => (
|
const canRegeneratePostThumbnail = (item) => (
|
||||||
item?.thumbnailStatus?.role === 'original'
|
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)
|
&& (item.thumbnailStatus.isFallbackActive || item.thumbnailStatus.hasThumbnail)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -246,9 +274,9 @@ const canRegeneratePostBodyThumbnail = (item) => (
|
|||||||
*/
|
*/
|
||||||
const getMediaItemKind = (item) => item?.kind || 'image'
|
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)))
|
const postBodyThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostBodyThumbnailDiskItem(item)))
|
||||||
|
|
||||||
@@ -298,7 +326,7 @@ const activeSystemMediaTitle = computed(() => {
|
|||||||
|
|
||||||
const activeSystemMediaHint = computed(() => {
|
const activeSystemMediaHint = computed(() => {
|
||||||
if (activeTab.value === 'postThumbnails') {
|
if (activeTab.value === 'postThumbnails') {
|
||||||
return '게시물 목록 카드에서 쓰는 자동 생성 이미지입니다. 원본 대표 이미지가 사용 중이고 이 썸네일 파일이 있으면 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 썸네일은 사용 중으로 표시되며, 썸네일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.'
|
return '게시물 목록 카드와 홈 Pages 진열장에서 쓰는 자동 생성 이미지입니다. 원본 이미지가 사용 중이고 이 썸네일 파일이 있으면 공개 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 썸네일은 사용 중으로 표시되며, 썸네일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTab.value === 'postBodyThumbnails') {
|
if (activeTab.value === 'postBodyThumbnails') {
|
||||||
@@ -1020,6 +1048,36 @@ const regeneratePostThumbnail = async (item) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택한 원본 이미지의 페이지 커버 썸네일을 다시 생성한다.
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
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 - 미디어 항목
|
* @param {Object} item - 미디어 항목
|
||||||
@@ -1534,18 +1592,29 @@ watch(filteredMediaUrls, (urls) => {
|
|||||||
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
|
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMedia.thumbnailStatus" class="admin-media__info-row">
|
<div v-if="selectedMedia.thumbnailStatus" class="admin-media__info-row">
|
||||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">카드 썸네일</dt>
|
<dt class="admin-media__info-label text-xs font-semibold text-muted">
|
||||||
|
{{ selectedMedia.thumbnailStatus.variant === 'pageCover' || selectedMedia.thumbnailStatus.role === 'pageCover' ? '페이지 커버 썸네일' : '카드 썸네일' }}
|
||||||
|
</dt>
|
||||||
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
|
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
|
||||||
<span v-if="selectedMedia.thumbnailStatus.role === 'card'">
|
<span v-if="selectedMedia.thumbnailStatus.role === 'card'">
|
||||||
자동 생성된 목록 카드용 썸네일입니다.
|
자동 생성된 목록 카드용 썸네일입니다.
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="selectedMedia.thumbnailStatus.role === 'pageCover'">
|
||||||
|
자동 생성된 Pages 진열장용 커버 썸네일입니다.
|
||||||
|
</span>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span v-if="selectedMedia.thumbnailStatus.hasThumbnail">
|
<span v-if="selectedMedia.thumbnailStatus.hasThumbnail">
|
||||||
생성됨: {{ selectedMedia.thumbnailStatus.thumbnailUrl }}
|
생성됨: {{ selectedMedia.thumbnailStatus.thumbnailUrl }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="selectedMedia.thumbnailStatus.isFallbackActive && selectedMedia.thumbnailStatus.variant === 'pageCover'" class="font-semibold text-amber-700">
|
||||||
|
페이지 커버 썸네일 없음. 현재 Pages 진열장에서 원본 이미지를 불러옵니다.
|
||||||
|
</span>
|
||||||
<span v-else-if="selectedMedia.thumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
|
<span v-else-if="selectedMedia.thumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
|
||||||
썸네일 없음. 현재 목록에서 원본 이미지를 불러옵니다.
|
썸네일 없음. 현재 목록에서 원본 이미지를 불러옵니다.
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="selectedMedia.thumbnailStatus.variant === 'pageCover'">
|
||||||
|
페이지 커버 썸네일 없음. 홈 Pages 진열장에서 사용되면 원본으로 대체됩니다.
|
||||||
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
썸네일 없음. 대표 이미지로 사용되면 원본으로 대체됩니다.
|
썸네일 없음. 대표 이미지로 사용되면 원본으로 대체됩니다.
|
||||||
</span>
|
</span>
|
||||||
@@ -1562,6 +1631,15 @@ watch(filteredMediaUrls, (urls) => {
|
|||||||
>
|
>
|
||||||
{{ regeneratingThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.thumbnailStatus.hasThumbnail ? '카드 썸네일 다시 생성' : '카드 썸네일 생성') }}
|
{{ regeneratingThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.thumbnailStatus.hasThumbnail ? '카드 썸네일 다시 생성' : '카드 썸네일 생성') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRegeneratePageCoverThumbnail(selectedMedia)"
|
||||||
|
class="admin-media__page-cover-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="regeneratePageCoverThumbnail(selectedMedia)"
|
||||||
|
>
|
||||||
|
{{ regeneratingThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.thumbnailStatus.hasThumbnail ? '페이지 커버 썸네일 다시 생성' : '페이지 커버 썸네일 생성') }}
|
||||||
|
</button>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMedia.bodyThumbnailStatus" class="admin-media__info-row">
|
<div v-if="selectedMedia.bodyThumbnailStatus" class="admin-media__info-row">
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ const pageOrderRemovedCount = computed(() => pageOrderRemovedPageIds.value.size)
|
|||||||
* @param {Object} page - 관리자 페이지
|
* @param {Object} page - 관리자 페이지
|
||||||
* @returns {string} 썸네일 URL
|
* @returns {string} 썸네일 URL
|
||||||
*/
|
*/
|
||||||
const getPageOrderCardImage = (page) => String(page?.featuredImage || page?.listingImage || '').trim()
|
const getPageOrderCardImage = (page) => String(page?.listingImageThumbnail || page?.featuredImage || page?.listingImage || '').trim()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 페이지를 현재 섹션에서 제거 목록에 추가한다.
|
* 페이지를 현재 섹션에서 제거 목록에 추가한다.
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ const pageShelves = computed(() => {
|
|||||||
|
|
||||||
shelf.pages.push({
|
shelf.pages.push({
|
||||||
title: page.listingTitle || page.title,
|
title: page.listingTitle || page.title,
|
||||||
image: page.featuredImage || '',
|
image: page.listingImageThumbnail || page.featuredImage || '',
|
||||||
to: `/pages/${page.slug}`,
|
to: `/pages/${page.slug}`,
|
||||||
isFeatured: Boolean(page.isFeatured)
|
isFeatured: Boolean(page.isFeatured)
|
||||||
})
|
})
|
||||||
|
|||||||
40
scripts/backfill-page-cover-thumbnails-production.sh
Executable file
40
scripts/backfill-page-cover-thumbnails-production.sh
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
APP_SERVICE=${APP_SERVICE:-sori-studio}
|
||||||
|
COMPOSE_ENV_FILE=
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
pick_compose_env_file() {
|
||||||
|
if [ -n "${ENV_FILE:-}" ] && [ -r "$ENV_FILE" ]; then
|
||||||
|
COMPOSE_ENV_FILE=$ENV_FILE
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -r .env.production ]; then
|
||||||
|
COMPOSE_ENV_FILE=.env.production
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -r .env ]; then
|
||||||
|
COMPOSE_ENV_FILE=.env
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPOSE_ENV_FILE=
|
||||||
|
}
|
||||||
|
|
||||||
|
compose() {
|
||||||
|
if [ -n "$COMPOSE_ENV_FILE" ]; then
|
||||||
|
docker compose --env-file "$COMPOSE_ENV_FILE" "$@"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
pick_compose_env_file
|
||||||
|
compose up -d "$APP_SERVICE"
|
||||||
|
compose exec -T "$APP_SERVICE" node .output/server/scripts/backfill-page-cover-thumbnails.js
|
||||||
107
scripts/backfill-page-cover-thumbnails.js
Normal file
107
scripts/backfill-page-cover-thumbnails.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import postgres from 'postgres'
|
||||||
|
import {
|
||||||
|
createPageCoverThumbnailForImageUrl,
|
||||||
|
getExistingPageCoverThumbnailUrl,
|
||||||
|
getPageCoverThumbnailUrl
|
||||||
|
} from '../server/utils/post-thumbnail-image.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬 실행용 환경 파일을 process.env에 반영한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const loadEnvironmentFile = () => {
|
||||||
|
const candidates = [
|
||||||
|
process.env.ENV_FILE,
|
||||||
|
'.env.development',
|
||||||
|
'.env'
|
||||||
|
].filter(Boolean)
|
||||||
|
const envFile = candidates.find((filePath) => existsSync(filePath))
|
||||||
|
|
||||||
|
if (!envFile) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = readFileSync(envFile, 'utf8').split(/\r?\n/)
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
|
||||||
|
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = trimmedLine.indexOf('=')
|
||||||
|
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmedLine.slice(0, separatorIndex).trim()
|
||||||
|
const rawValue = trimmedLine.slice(separatorIndex + 1).trim()
|
||||||
|
|
||||||
|
if (!key || process.env[key] !== undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = rawValue.replace(/^['"]|['"]$/g, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈 Pages 진열장 커버 이미지 목록을 조회한다.
|
||||||
|
* @returns {Promise<Array<{ featuredImage: string | null }>>} 페이지 커버 이미지 목록
|
||||||
|
*/
|
||||||
|
const listPageCoverImages = async () => {
|
||||||
|
loadEnvironmentFile()
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
throw new Error('DATABASE_URL 환경 변수가 필요합니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = postgres(process.env.DATABASE_URL, {
|
||||||
|
max: 1,
|
||||||
|
idle_timeout: 5,
|
||||||
|
connect_timeout: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT featured_image
|
||||||
|
FROM pages
|
||||||
|
WHERE featured_image IS NOT NULL
|
||||||
|
AND is_listed = true
|
||||||
|
ORDER BY listing_sort_order ASC, updated_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
featuredImage: row.featured_image
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
await sql.end({ timeout: 5 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = await listPageCoverImages()
|
||||||
|
const coverImageUrls = [...new Set(pages
|
||||||
|
.map((page) => page.featuredImage)
|
||||||
|
.filter((url) => getPageCoverThumbnailUrl(url)))]
|
||||||
|
let createdCount = 0
|
||||||
|
let skippedCount = 0
|
||||||
|
|
||||||
|
for (const imageUrl of coverImageUrls) {
|
||||||
|
if (getExistingPageCoverThumbnailUrl(imageUrl)) {
|
||||||
|
skippedCount += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailUrl = await createPageCoverThumbnailForImageUrl(imageUrl)
|
||||||
|
|
||||||
|
if (thumbnailUrl) {
|
||||||
|
createdCount += 1
|
||||||
|
} else {
|
||||||
|
skippedCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`페이지 커버 썸네일 생성 완료: 생성 ${createdCount}개, 건너뜀 ${skippedCount}개\n`)
|
||||||
@@ -35,8 +35,10 @@ import {
|
|||||||
} from '../../lib/upload-size-limit.js'
|
} from '../../lib/upload-size-limit.js'
|
||||||
import { normalizeSocialLinks } from '../../lib/social-links.js'
|
import { normalizeSocialLinks } from '../../lib/social-links.js'
|
||||||
import {
|
import {
|
||||||
|
createPageCoverThumbnailForImageUrl,
|
||||||
createPostBodyThumbnailsForContent,
|
createPostBodyThumbnailsForContent,
|
||||||
createPostThumbnailForImageUrl,
|
createPostThumbnailForImageUrl,
|
||||||
|
getExistingPageCoverThumbnailUrl,
|
||||||
getExistingPostThumbnailUrl
|
getExistingPostThumbnailUrl
|
||||||
} from '../utils/post-thumbnail-image.js'
|
} from '../utils/post-thumbnail-image.js'
|
||||||
import { getPostgresClient } from './postgres-client'
|
import { getPostgresClient } from './postgres-client'
|
||||||
@@ -94,6 +96,7 @@ const mapPageRow = (row) => ({
|
|||||||
renderMode: row.render_mode || 'markdown',
|
renderMode: row.render_mode || 'markdown',
|
||||||
status: row.status || 'published',
|
status: row.status || 'published',
|
||||||
featuredImage: row.featured_image,
|
featuredImage: row.featured_image,
|
||||||
|
listingImageThumbnail: getExistingPageCoverThumbnailUrl(row.featured_image),
|
||||||
isListed: Boolean(row.is_listed),
|
isListed: Boolean(row.is_listed),
|
||||||
isFeatured: Boolean(row.is_featured),
|
isFeatured: Boolean(row.is_featured),
|
||||||
listingSection: row.listing_section || '',
|
listingSection: row.listing_section || '',
|
||||||
@@ -1121,6 +1124,8 @@ export const createAdminPage = async (input) => {
|
|||||||
RETURNING *
|
RETURNING *
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await createPageCoverThumbnailForImageUrl(input.featuredImage)
|
||||||
|
|
||||||
return mapPageRow(rows[0])
|
return mapPageRow(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,6 +1162,10 @@ export const updateAdminPage = async (id, input) => {
|
|||||||
RETURNING *
|
RETURNING *
|
||||||
`
|
`
|
||||||
|
|
||||||
|
if (rows[0]) {
|
||||||
|
await createPageCoverThumbnailForImageUrl(input.featuredImage)
|
||||||
|
}
|
||||||
|
|
||||||
return rows[0] ? mapPageRow(rows[0]) : null
|
return rows[0] ? mapPageRow(rows[0]) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { createError, readBody } from 'h3'
|
import { createError, readBody } from 'h3'
|
||||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||||
import {
|
import {
|
||||||
|
createPageCoverThumbnailForImageUrl,
|
||||||
createPostBodyThumbnailForImageUrl,
|
createPostBodyThumbnailForImageUrl,
|
||||||
createPostThumbnailForImageUrl
|
createPostThumbnailForImageUrl
|
||||||
} from '../../../../utils/post-thumbnail-image.js'
|
} from '../../../../utils/post-thumbnail-image.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 이미지 파생 파일 재생성 API
|
* 이미지 파생 파일 재생성 API
|
||||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
* @returns {Promise<{ thumbnailUrl: string }>} 생성된 썸네일 URL
|
* @returns {Promise<{ thumbnailUrl: string }>} 생성된 썸네일 URL
|
||||||
*/
|
*/
|
||||||
@@ -14,9 +15,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
requireAdminSession(event)
|
requireAdminSession(event)
|
||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const kind = body?.kind === 'body' ? 'body' : 'card'
|
const kind = ['body', 'pageCover'].includes(body?.kind) ? body.kind : 'card'
|
||||||
const thumbnailUrl = kind === 'body'
|
const thumbnailUrl = kind === 'body'
|
||||||
? await createPostBodyThumbnailForImageUrl(body?.url)
|
? await createPostBodyThumbnailForImageUrl(body?.url)
|
||||||
|
: kind === 'pageCover'
|
||||||
|
? await createPageCoverThumbnailForImageUrl(body?.url)
|
||||||
: await createPostThumbnailForImageUrl(body?.url)
|
: await createPostThumbnailForImageUrl(body?.url)
|
||||||
|
|
||||||
if (!thumbnailUrl) {
|
if (!thumbnailUrl) {
|
||||||
@@ -24,6 +27,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: kind === 'body'
|
message: kind === 'body'
|
||||||
? '본문 표시용 이미지를 만들 수 있는 게시물 이미지가 아닙니다.'
|
? '본문 표시용 이미지를 만들 수 있는 게시물 이미지가 아닙니다.'
|
||||||
|
: kind === 'pageCover'
|
||||||
|
? '페이지 커버 썸네일을 만들 수 있는 이미지가 아닙니다.'
|
||||||
: '카드 썸네일을 만들 수 있는 게시물 대표 이미지가 아닙니다.'
|
: '카드 썸네일을 만들 수 있는 게시물 대표 이미지가 아닙니다.'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const contentTypes = new Map([
|
|||||||
['.svg', 'image/svg+xml'],
|
['.svg', 'image/svg+xml'],
|
||||||
['.ico', 'image/x-icon']
|
['.ico', 'image/x-icon']
|
||||||
])
|
])
|
||||||
const regeneratableUploadPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-(?:card|body|body-720|lightbox)\.webp$/i
|
const regeneratableUploadPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-(?:card|page-cover|body|body-720|lightbox)\.webp$/i
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 업로드 파일 캐시 정책을 반환한다.
|
* 업로드 파일 캐시 정책을 반환한다.
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { createError } from 'h3'
|
|||||||
import { getSiteSettings, listAdminPosts, listAdminTags, listPages } from '../repositories/content-repository'
|
import { getSiteSettings, listAdminPosts, listAdminTags, listPages } from '../repositories/content-repository'
|
||||||
import { getPostgresClient } from '../repositories/postgres-client'
|
import { getPostgresClient } from '../repositories/postgres-client'
|
||||||
import {
|
import {
|
||||||
|
getPageCoverThumbnailUrl,
|
||||||
getPostBodyPreviewThumbnailUrl,
|
getPostBodyPreviewThumbnailUrl,
|
||||||
getPostBodyThumbnailUrl,
|
getPostBodyThumbnailUrl,
|
||||||
getPostLightboxThumbnailUrl,
|
getPostLightboxThumbnailUrl,
|
||||||
getPostThumbnailUrl,
|
getPostThumbnailUrl,
|
||||||
|
isPageCoverThumbnailUrl,
|
||||||
isPostBodyThumbnailUrl,
|
isPostBodyThumbnailUrl,
|
||||||
isPostCardThumbnailUrl,
|
isPostCardThumbnailUrl,
|
||||||
isPostThumbnailSource
|
isPostThumbnailSource
|
||||||
@@ -584,6 +586,17 @@ const buildPostThumbnailOwnerMap = (items) => buildPostDerivativeOwnerMap(
|
|||||||
isPostCardThumbnailUrl
|
isPostCardThumbnailUrl
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 커버 썸네일 URL별 원본 URL 맵을 만든다.
|
||||||
|
* @param {Array<Object>} items - 미디어 항목 목록
|
||||||
|
* @returns {Map<string, string>} 페이지 커버 썸네일 URL별 원본 URL
|
||||||
|
*/
|
||||||
|
const buildPageCoverThumbnailOwnerMap = (items) => buildPostDerivativeOwnerMap(
|
||||||
|
items,
|
||||||
|
getPageCoverThumbnailUrl,
|
||||||
|
isPageCoverThumbnailUrl
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 본문 표시용 이미지 URL별 원본 URL 맵을 만든다.
|
* 본문 표시용 이미지 URL별 원본 URL 맵을 만든다.
|
||||||
* @param {Array<Object>} items - 미디어 항목 목록
|
* @param {Array<Object>} items - 미디어 항목 목록
|
||||||
@@ -641,6 +654,32 @@ const getPostThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages, site
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 커버 썸네일의 간접 사용처를 조회한다.
|
||||||
|
* @param {string} url - 페이지 커버 썸네일 URL
|
||||||
|
* @param {Map<string, string>} ownerByThumbnailUrl - 썸네일 URL별 원본 URL
|
||||||
|
* @param {Array<Object>} posts - 게시물 목록
|
||||||
|
* @param {Array<Object>} pages - 페이지 목록
|
||||||
|
* @param {Object} siteSettings - 사이트 설정
|
||||||
|
* @returns {Array<Object>} 간접 사용처 목록
|
||||||
|
*/
|
||||||
|
const getPageCoverThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages, siteSettings) => {
|
||||||
|
const originalUrl = ownerByThumbnailUrl.get(url)
|
||||||
|
|
||||||
|
if (!originalUrl) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDirectMediaUsage(originalUrl, posts, pages, siteSettings)
|
||||||
|
.filter((usage) => usage.type === 'page' && usage.location === 'featuredImage')
|
||||||
|
.map((usage) => ({
|
||||||
|
...usage,
|
||||||
|
location: 'pageCoverThumbnail',
|
||||||
|
label: '페이지 커버 썸네일',
|
||||||
|
sourceUrl: originalUrl
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 본문 표시용 이미지의 간접 사용처를 조회한다.
|
* 게시물 본문 표시용 이미지의 간접 사용처를 조회한다.
|
||||||
* @param {string} url - 본문 표시용 이미지 URL
|
* @param {string} url - 본문 표시용 이미지 URL
|
||||||
@@ -675,7 +714,7 @@ const getPostBodyThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages,
|
|||||||
* @returns {Object|null} 카드 썸네일 상태
|
* @returns {Object|null} 카드 썸네일 상태
|
||||||
*/
|
*/
|
||||||
const getPostThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
const getPostThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||||
if (isPostCardThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
if (isPostCardThumbnailUrl(item.url) || isPageCoverThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,16 +725,47 @@ const getPostThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasThumbnail = itemUrlSet.has(thumbnailUrl)
|
const hasThumbnail = itemUrlSet.has(thumbnailUrl)
|
||||||
const isFeaturedImage = directUsage.some((usage) => usage.location === 'featuredImage')
|
const isFeaturedImage = directUsage.some((usage) => usage.type === 'post' && usage.location === 'featuredImage')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
role: 'original',
|
role: 'original',
|
||||||
|
variant: 'postCard',
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
hasThumbnail,
|
hasThumbnail,
|
||||||
isFallbackActive: isFeaturedImage && !hasThumbnail
|
isFallbackActive: isFeaturedImage && !hasThumbnail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 커버 원본 이미지의 썸네일 상태를 조회한다.
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @param {Set<string>} itemUrlSet - 전체 미디어 URL 집합
|
||||||
|
* @param {Array<Object>} directUsage - 원본 URL 직접 사용처
|
||||||
|
* @returns {Object|null} 페이지 커버 썸네일 상태
|
||||||
|
*/
|
||||||
|
const getPageCoverThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||||
|
if (isPostCardThumbnailUrl(item.url) || isPageCoverThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailUrl = getPageCoverThumbnailUrl(item.url)
|
||||||
|
|
||||||
|
if (!thumbnailUrl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasThumbnail = itemUrlSet.has(thumbnailUrl)
|
||||||
|
const isPageCoverImage = directUsage.some((usage) => usage.type === 'page' && usage.location === 'featuredImage')
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: 'original',
|
||||||
|
variant: 'pageCover',
|
||||||
|
thumbnailUrl,
|
||||||
|
hasThumbnail,
|
||||||
|
isFallbackActive: isPageCoverImage && !hasThumbnail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 업로드 이미지의 본문 표시용 이미지 상태를 조회한다.
|
* 게시물 업로드 이미지의 본문 표시용 이미지 상태를 조회한다.
|
||||||
* @param {Object} item - 미디어 항목
|
* @param {Object} item - 미디어 항목
|
||||||
@@ -704,7 +774,7 @@ const getPostThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
|||||||
* @returns {Object|null} 본문 표시용 이미지 상태
|
* @returns {Object|null} 본문 표시용 이미지 상태
|
||||||
*/
|
*/
|
||||||
const getPostBodyThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
const getPostBodyThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||||
if (isPostCardThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
if (isPostCardThumbnailUrl(item.url) || isPageCoverThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,6 +879,7 @@ export const listMediaItems = async () => {
|
|||||||
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
|
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
|
||||||
const itemUrlSet = new Set(items.map((item) => item.url))
|
const itemUrlSet = new Set(items.map((item) => item.url))
|
||||||
const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items)
|
const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items)
|
||||||
|
const pageCoverThumbnailOwnerByUrl = buildPageCoverThumbnailOwnerMap(items)
|
||||||
const bodyThumbnailOwnerByUrl = buildPostBodyThumbnailOwnerMap(items)
|
const bodyThumbnailOwnerByUrl = buildPostBodyThumbnailOwnerMap(items)
|
||||||
const itemsWithUsage = items.map((item) => {
|
const itemsWithUsage = items.map((item) => {
|
||||||
const rawCategory = metadataMap[item.url]?.category ?? item.category
|
const rawCategory = metadataMap[item.url]?.category ?? item.category
|
||||||
@@ -816,8 +887,10 @@ export const listMediaItems = async () => {
|
|||||||
const avatarOwner = avatarOwnerByUrl.get(item.url) || null
|
const avatarOwner = avatarOwnerByUrl.get(item.url) || null
|
||||||
const directUsage = getDirectMediaUsage(item.url, posts, pages, siteSettings, tags)
|
const directUsage = getDirectMediaUsage(item.url, posts, pages, siteSettings, tags)
|
||||||
const thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings)
|
const thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||||
|
const pageCoverThumbnailUsage = getPageCoverThumbnailMediaUsage(item.url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||||
const bodyThumbnailUsage = getPostBodyThumbnailMediaUsage(item.url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
|
const bodyThumbnailUsage = getPostBodyThumbnailMediaUsage(item.url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||||
const originalUrl = thumbnailOwnerByUrl.get(item.url) || ''
|
const originalUrl = thumbnailOwnerByUrl.get(item.url) || ''
|
||||||
|
const pageCoverOriginalUrl = pageCoverThumbnailOwnerByUrl.get(item.url) || ''
|
||||||
const bodyOriginalUrl = bodyThumbnailOwnerByUrl.get(item.url) || ''
|
const bodyOriginalUrl = bodyThumbnailOwnerByUrl.get(item.url) || ''
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -826,6 +899,7 @@ export const listMediaItems = async () => {
|
|||||||
usage: [
|
usage: [
|
||||||
...directUsage,
|
...directUsage,
|
||||||
...thumbnailUsage,
|
...thumbnailUsage,
|
||||||
|
...pageCoverThumbnailUsage,
|
||||||
...bodyThumbnailUsage
|
...bodyThumbnailUsage
|
||||||
],
|
],
|
||||||
thumbnailStatus: originalUrl
|
thumbnailStatus: originalUrl
|
||||||
@@ -833,7 +907,12 @@ export const listMediaItems = async () => {
|
|||||||
role: 'card',
|
role: 'card',
|
||||||
originalUrl
|
originalUrl
|
||||||
}
|
}
|
||||||
: getPostThumbnailStatus(item, itemUrlSet, directUsage),
|
: pageCoverOriginalUrl
|
||||||
|
? {
|
||||||
|
role: 'pageCover',
|
||||||
|
originalUrl: pageCoverOriginalUrl
|
||||||
|
}
|
||||||
|
: getPostThumbnailStatus(item, itemUrlSet, directUsage) || getPageCoverThumbnailStatus(item, itemUrlSet, directUsage),
|
||||||
bodyThumbnailStatus: bodyOriginalUrl
|
bodyThumbnailStatus: bodyOriginalUrl
|
||||||
? {
|
? {
|
||||||
role: 'body',
|
role: 'body',
|
||||||
@@ -966,6 +1045,7 @@ export const deleteMediaItem = async (url) => {
|
|||||||
const usage = [
|
const usage = [
|
||||||
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
|
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
|
||||||
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings),
|
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings),
|
||||||
|
...getPageCoverThumbnailMediaUsage(url, buildPageCoverThumbnailOwnerMap(items), posts, pages, siteSettings),
|
||||||
...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings)
|
...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -994,10 +1074,10 @@ export const deleteMediaItem = async (url) => {
|
|||||||
* @returns {Promise<Object>} 변경된 미디어 항목
|
* @returns {Promise<Object>} 변경된 미디어 항목
|
||||||
*/
|
*/
|
||||||
export const renameMediaItem = async (url, name) => {
|
export const renameMediaItem = async (url, name) => {
|
||||||
if (isPostCardThumbnailUrl(url) || isPostBodyThumbnailUrl(url)) {
|
if (isPostCardThumbnailUrl(url) || isPageCoverThumbnailUrl(url) || isPostBodyThumbnailUrl(url)) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
message: '게시물 자동 생성 이미지는 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다.'
|
message: '자동 생성 이미지는 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다.'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1011,6 +1091,7 @@ export const renameMediaItem = async (url, name) => {
|
|||||||
const usage = [
|
const usage = [
|
||||||
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
|
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
|
||||||
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings),
|
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings),
|
||||||
|
...getPageCoverThumbnailMediaUsage(url, buildPageCoverThumbnailOwnerMap(items), posts, pages, siteSettings),
|
||||||
...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings)
|
...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ import { mkdir, stat } from 'node:fs/promises'
|
|||||||
import { dirname, extname, join, posix } from 'node:path'
|
import { dirname, extname, join, posix } from 'node:path'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import {
|
import {
|
||||||
|
getPageCoverThumbnailUrl,
|
||||||
getPostBodyPreviewThumbnailUrl,
|
getPostBodyPreviewThumbnailUrl,
|
||||||
getPostBodyThumbnailUrl,
|
getPostBodyThumbnailUrl,
|
||||||
getPostLightboxThumbnailUrl,
|
getPostLightboxThumbnailUrl,
|
||||||
|
isPageCoverThumbnailUrl,
|
||||||
isPostBodyThumbnailUrl,
|
isPostBodyThumbnailUrl,
|
||||||
isPostCardThumbnailUrl
|
isPostCardThumbnailUrl
|
||||||
} from '../../lib/post-card-thumbnail-url.js'
|
} from '../../lib/post-card-thumbnail-url.js'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
getPageCoverThumbnailUrl,
|
||||||
getPostBodyPreviewThumbnailUrl,
|
getPostBodyPreviewThumbnailUrl,
|
||||||
getPostBodyThumbnailUrl,
|
getPostBodyThumbnailUrl,
|
||||||
getPostLightboxThumbnailUrl,
|
getPostLightboxThumbnailUrl,
|
||||||
|
isPageCoverThumbnailUrl,
|
||||||
isPostBodyThumbnailUrl,
|
isPostBodyThumbnailUrl,
|
||||||
isPostCardThumbnailUrl
|
isPostCardThumbnailUrl
|
||||||
}
|
}
|
||||||
@@ -25,6 +29,9 @@ export const POST_BODY_IMAGE_WIDTH = 1600
|
|||||||
export const POST_BODY_PREVIEW_IMAGE_WIDTH = 720
|
export const POST_BODY_PREVIEW_IMAGE_WIDTH = 720
|
||||||
export const POST_LIGHTBOX_IMAGE_WIDTH = 2560
|
export const POST_LIGHTBOX_IMAGE_WIDTH = 2560
|
||||||
export const POST_BODY_IMAGE_QUALITY = 84
|
export const POST_BODY_IMAGE_QUALITY = 84
|
||||||
|
export const PAGE_COVER_THUMBNAIL_WIDTH = 450
|
||||||
|
export const PAGE_COVER_THUMBNAIL_HEIGHT = 600
|
||||||
|
export const PAGE_COVER_THUMBNAIL_QUALITY = 84
|
||||||
|
|
||||||
const postUploadUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^/?#]+$/i
|
const postUploadUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^/?#]+$/i
|
||||||
const postUploadImageUrlPattern = /\/uploads\/posts\/\d{4}\/\d{2}\/[^/\s"'()<>\]]+\.(?:jpe?g|png|webp)/gi
|
const postUploadImageUrlPattern = /\/uploads\/posts\/\d{4}\/\d{2}\/[^/\s"'()<>\]]+\.(?:jpe?g|png|webp)/gi
|
||||||
@@ -154,6 +161,21 @@ export const getPostLightboxThumbnailDiskPath = (imageUrl) => {
|
|||||||
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 커버 썸네일 URL의 디스크 경로를 조회한다.
|
||||||
|
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||||
|
* @returns {string} 페이지 커버 썸네일 디스크 경로
|
||||||
|
*/
|
||||||
|
export const getPageCoverThumbnailDiskPath = (imageUrl) => {
|
||||||
|
const thumbnailUrl = getPageCoverThumbnailUrl(imageUrl)
|
||||||
|
|
||||||
|
if (!thumbnailUrl) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 원본 이미지 URL의 디스크 경로를 조회한다.
|
* 게시물 원본 이미지 URL의 디스크 경로를 조회한다.
|
||||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||||
@@ -245,6 +267,21 @@ export const getExistingPostLightboxThumbnailUrl = (imageUrl) => {
|
|||||||
return existsSync(getPostLightboxThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
|
return existsSync(getPostLightboxThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미 생성된 페이지 커버 썸네일 URL을 조회한다.
|
||||||
|
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||||
|
* @returns {string} 존재하는 페이지 커버 썸네일 URL
|
||||||
|
*/
|
||||||
|
export const getExistingPageCoverThumbnailUrl = (imageUrl) => {
|
||||||
|
const thumbnailUrl = getPageCoverThumbnailUrl(imageUrl)
|
||||||
|
|
||||||
|
if (!thumbnailUrl) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return existsSync(getPageCoverThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 대표 이미지의 카드 썸네일을 생성하거나 다시 생성한다.
|
* 게시물 대표 이미지의 카드 썸네일을 생성하거나 다시 생성한다.
|
||||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||||
@@ -282,6 +319,43 @@ export const createPostThumbnailForImageUrl = async (imageUrl) => {
|
|||||||
return thumbnailUrl
|
return thumbnailUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 홈 진열장용 3:4 커버 썸네일을 생성하거나 다시 생성한다.
|
||||||
|
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||||
|
* @returns {Promise<string>} 생성된 페이지 커버 썸네일 URL
|
||||||
|
*/
|
||||||
|
export const createPageCoverThumbnailForImageUrl = async (imageUrl) => {
|
||||||
|
const sourcePath = getPostImageDiskPath(imageUrl)
|
||||||
|
const thumbnailUrl = getPageCoverThumbnailUrl(imageUrl)
|
||||||
|
const thumbnailPath = getPageCoverThumbnailDiskPath(imageUrl)
|
||||||
|
|
||||||
|
if (!sourcePath || !thumbnailUrl || !thumbnailPath) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stat(sourcePath)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(dirname(thumbnailPath), { recursive: true })
|
||||||
|
|
||||||
|
await sharp(sourcePath)
|
||||||
|
.rotate()
|
||||||
|
.resize({
|
||||||
|
width: PAGE_COVER_THUMBNAIL_WIDTH,
|
||||||
|
height: PAGE_COVER_THUMBNAIL_HEIGHT,
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'centre',
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
.webp({ quality: PAGE_COVER_THUMBNAIL_QUALITY })
|
||||||
|
.toFile(thumbnailPath)
|
||||||
|
|
||||||
|
return thumbnailUrl
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 본문 이미지의 표시용 WebP 이미지를 생성하거나 다시 생성한다.
|
* 게시물 본문 이미지의 표시용 WebP 이미지를 생성하거나 다시 생성한다.
|
||||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||||
|
|||||||
Reference in New Issue
Block a user