미디어 본문 파생 이미지 분류 수정
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.10.9에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.10.10에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-07-01 v1.10.10 — 본문 파생 이미지는 원본 라이브러리에서 숨긴다
|
||||
|
||||
본문 이미지 최적화를 위해 하나의 원본에서 1600px 표시용, 720px 표시용, 2560px 라이트박스용 WebP가 생성된다. 이 파일들은 사용자가 직접 고른 원본이 아니라 원본 본문 이미지에 딸린 시스템 파생 파일이므로, 미디어 라이브러리의 원본 목록과 미사용 일괄 삭제 흐름에 섞이면 관리자가 실수로 삭제하기 쉽다. 따라서 관리자 미디어는 세 파생 파일을 모두 본문 이미지 탭으로 분류하고, 서버 사용처 계산도 세 파일을 원본 본문 이미지 사용처에 연결한다. 파일명 자체를 해상도형으로 바꾸는 작업은 기존 운영 볼륨 정리까지 필요하므로 별도 과제로 둔다.
|
||||
|
||||
## 2026-07-01 v1.10.9 — 업로드 묶음 제한은 운영자가 설정에서 조절한다
|
||||
|
||||
파일별 최대 크기는 서버 운영 정책에 가까워 환경 변수로 유지하지만, 한 번에 몇 개·총 몇 MB까지 올릴지는 운영자가 콘텐츠 작업량에 따라 자주 조절할 수 있다. NAS 설정 파일을 매번 수정하는 방식은 관리 부담이 커서, 사이트 설정 POST 설정 안에 1회 업로드 파일 수와 전체 용량을 추가했다. 서버는 최종 검증을 담당하고, 관리자 미디어·글쓰기 화면은 같은 설정값으로 사전 오류를 보여 준다. 다만 현재 multipart 파서는 요청 본문을 메모리에 받은 뒤 검사하므로, 더 큰 규모의 업로드 안정화는 스트리밍 파서 도입을 별도 과제로 둔다.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·1회 파일 수/총량 제한 정규화·용량 문구(서버·클라이언트 공용) |
|
||||
| lib/post-card-thumbnail-url.js | 게시물 카드 썸네일 `thumbs/*-card.webp`와 본문 표시용 이미지 `thumbs/*-body.webp` URL 판별(대표 이미지·본문 이미지·갤러리 선택 및 서버 검증 공용) |
|
||||
| lib/post-card-thumbnail-url.js | 게시물 카드 썸네일 `thumbs/*-card.webp`와 본문 표시용 1600px/720px·라이트박스용 2560px 파생 이미지 URL 판별(대표 이미지·본문 이미지·갤러리 선택 및 서버 검증 공용) |
|
||||
| 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 우선순위(수동 썸네일 > 서버 캐시 > 외부 프록시) |
|
||||
|
||||
@@ -268,7 +268,7 @@ components/content/
|
||||
- 파일: `:::file` ~ `:::` (`url`, `title`, `description`, `name`, `size`) — 다운로드 링크 카드
|
||||
- 렌더링: `ProseVideo.vue`, `ProseAudio.vue`, `ProseFile.vue`
|
||||
- 관리자 슬래시: `/video`, `/audio`, `/file`로 빈 템플릿 삽입 후 URL·메타 수정
|
||||
- 관리자 미디어 화면은 미디어 라이브러리·카드 썸네일·프로필 이미지 탭으로 구분한다. 미디어 라이브러리 탭은 원본 업로드 파일만 표시하고, 게시물 목록용 `thumbs/*-card.webp` 파생 파일은 카드 썸네일 탭에만 표시한다. 게시물 글쓰기의 대표 이미지와 본문 `/이미지`·`/갤러리` 선택 모달은 모두 카드 썸네일 파생 파일을 숨기고 원본 이미지만 선택하게 한다. 본문 미디어 모달은 업로드 탭을 먼저 열고, 업로드 완료 후 라이브러리에서 새 파일을 자동 선택한다.
|
||||
- 관리자 미디어 화면은 미디어 라이브러리·카드 썸네일·본문 이미지·프로필 이미지 탭으로 구분한다. 미디어 라이브러리 탭은 원본 업로드 파일만 표시하고, 게시물 목록용 `thumbs/*-card.webp` 파생 파일은 카드 썸네일 탭에만, 본문용 `thumbs/*-body.webp`·`*-body-720.webp`·`*-lightbox.webp` 파생 파일은 본문 이미지 탭에만 표시한다. 게시물 글쓰기의 대표 이미지와 본문 `/이미지`·`/갤러리` 선택 모달은 파생 파일을 숨기고 원본 이미지만 선택하게 한다. 본문 미디어 모달은 업로드 탭을 먼저 열고, 업로드 완료 후 라이브러리에서 새 파일을 자동 선택한다.
|
||||
- 게시물 생성·수정 API는 `thumbs/*-card.webp` 카드 썸네일 URL을 대표 이미지로 저장하지 못하게 검증한다. 카드 썸네일은 원본 대표 이미지 저장 후 목록 표시용으로만 자동 생성한다.
|
||||
- 링크·iframe·HTML 블록은 위 블록과 불필요하게 벌어지지 않도록 상단 외부 여백 없이 표시하고, 다음 블록과 구분되는 하단 여백만 유지한다.
|
||||
- 관리자 미디어 화면의 미디어 라이브러리 탭은 전체·이미지·영상·음악·파일 종류 필터와 미사용 필터를 제공한다. 미사용은 게시물·페이지·사이트 설정·회원 프로필에서 참조되지 않는 항목을 의미한다. 비디오 항목은 브라우저에서 초반 프레임을 캔버스로 추출해 목록 썸네일로 표시하고, 추출 실패 시 `video` placeholder를 유지한다.
|
||||
@@ -774,7 +774,7 @@ components/content/
|
||||
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `` 또는 파일명 캡션 토글 시 `` 형식으로 저장한다. 단독 이미지 파일 URL(`jpg`, `png`, `webp`, `gif`, `avif`, `svg`) 한 줄은 임베드가 아니라 이미지 블록으로 렌더링한다. 게시물 저장 시 `/uploads/posts/YYYY/MM/` 아래의 본문 `jpg`·`png`·`webp` 이미지는 원본 옆 `thumbs/*-body.webp` 표시용 이미지로 자동 생성하며, 공개 본문 단일 이미지와 갤러리는 이 표시용 이미지를 우선 사용하고 실패하면 원본 URL로 대체한다. 라이트박스는 원본 이미지 URL 목록을 기준으로 연다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `` 또는 파일명 캡션 토글 시 `` 형식으로 저장한다. 단독 이미지 파일 URL(`jpg`, `png`, `webp`, `gif`, `avif`, `svg`) 한 줄은 임베드가 아니라 이미지 블록으로 렌더링한다. 게시물 저장 시 `/uploads/posts/YYYY/MM/` 아래의 본문 `jpg`·`png`·`webp` 이미지는 원본 옆 1600px `thumbs/*-body.webp`, 720px `thumbs/*-body-720.webp`, 2560px `thumbs/*-lightbox.webp` 파생 이미지로 자동 생성한다. 공개 본문 단일 이미지와 갤러리는 720px·1600px 표시용 이미지를 우선 사용하고 실패하면 원본 URL로 대체하며, 라이트박스는 2560px 파생 이미지를 우선 사용하고 실패하면 원본 URL로 대체한다.
|
||||
- 이미지/갤러리 삽입 시 캡션은 기본 비우며, 블록 설정 패널에서 **파일명을 캡션으로 사용** 토글로 이미지 아래에 URL 파일명을 표시한다.
|
||||
- 라이브 모드 이미지 블록은 hover/focus 시 우측 상단에 `삭제` 버튼만 표시한다. 이미지 URL·캡션·파일명 캡션 사용 여부 수정은 해당 이미지 블록에 포커스했을 때 오른쪽 이미지 설정 패널에서 처리한다.
|
||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
@@ -918,7 +918,7 @@ components/content/
|
||||
- 관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록).
|
||||
- 업로드 파일 크기 제한은 종류별 환경 변수를 따른다. 이미지·아바타·로고 등은 `MAX_FILE_SIZE`(기본 10MB), 비디오는 `MAX_VIDEO_FILE_SIZE`(기본 200MB), 오디오는 `MAX_AUDIO_FILE_SIZE`(기본 50MB), 문서·ZIP 등은 `MAX_DOCUMENT_FILE_SIZE`(기본 50MB). 관리자 게시물·페이지용 미디어 업로드의 1회 파일 수와 전체 용량은 사이트 설정 POST 설정의 업로드 제한을 따른다.
|
||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||
- 관리자 미디어 화면 상단에 **미디어 라이브러리**·**카드 썸네일**·**본문 이미지**·**프로필 이미지** 탭을 두어, 라이브러리 탭에서는 원본 업로드 파일만, 카드 썸네일 탭에서는 `/posts/YYYY/MM/thumbs/*-card.webp` 파일만, 본문 이미지 탭에서는 `/posts/YYYY/MM/thumbs/*-body.webp` 파일만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. 라이브러리 탭의 파일 종류 필터는 `전체/이미지/영상/음악/파일`을 표시하고 사용 상태 필터는 중복 `전체` 배지를 피하기 위해 `미사용` 토글만 표시한다. 시스템 파생 탭에서는 전체/미사용 배지형 필터를 표시하고, 목록 카드 오른쪽 위에는 실제 사용 개수 숫자 배지를 표시한다. 카드 썸네일 탭과 본문 이미지 탭에서는 모든 항목이 해당 파생 파일이므로 각각 `카드`, `본문` 배지는 표시하지 않는다.
|
||||
- 관리자 미디어 화면 상단에 **미디어 라이브러리**·**카드 썸네일**·**본문 이미지**·**프로필 이미지** 탭을 두어, 라이브러리 탭에서는 원본 업로드 파일만, 카드 썸네일 탭에서는 `/posts/YYYY/MM/thumbs/*-card.webp` 파일만, 본문 이미지 탭에서는 `/posts/YYYY/MM/thumbs/*-body.webp`, `*-body-720.webp`, `*-lightbox.webp` 파일만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. 본문 이미지 탭의 상세 정보는 1600px, 720px, 2560px 용도를 구분해 표시한다. 라이브러리 탭의 파일 종류 필터는 `전체/이미지/영상/음악/파일`을 표시하고 사용 상태 필터는 중복 `전체` 배지를 피하기 위해 `미사용` 토글만 표시한다. 시스템 파생 탭에서는 전체/미사용 배지형 필터를 표시하고, 목록 카드 오른쪽 위에는 실제 사용 개수 숫자 배지를 표시한다. 카드 썸네일 탭과 본문 이미지 탭에서는 모든 항목이 해당 파생 파일이므로 각각 `카드`, `본문` 배지는 표시하지 않는다.
|
||||
- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||
- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다.
|
||||
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다. 현재 필터 결과의 전체 선택은 사용 중이 아니어서 삭제 가능한 항목만 대상으로 한다.
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
- 업로드 파일 캐시 정책 개선.
|
||||
- 현재 `/uploads/**` 응답은 `cache-control: no-cache`라 이미지가 많은 페이지에서 재검증 요청이 늘어날 수 있음.
|
||||
- 업로드 파일명은 충돌 방지로 고유하게 저장되므로 `public, max-age=31536000, immutable` 적용 가능 여부 검토.
|
||||
- 본문 파생 이미지 파일명 정리 검토.
|
||||
- 현재 본문 파생 이미지는 `*-body.webp`(1600px), `*-body-720.webp`(720px), `*-lightbox.webp`(2560px) 이름을 사용함.
|
||||
- 운영 볼륨에 이미 생성된 파일이 있으므로 `*-body-1600.webp`, `*-body-2560.webp`처럼 해상도형 이름으로 바꾸려면 신규 생성 규칙, 기존 파일 백필, 레거시 파일 정리 절차를 함께 설계해야 함.
|
||||
- 공개 통합 검색 인덱스 개선.
|
||||
- 현재 검색은 게시물·페이지 `content`에 `lower(content)` 부분 일치를 수행함.
|
||||
- 게시물이 수백~수천 개로 늘면 검색 모달 입력마다 DB 스캔 비용이 커질 수 있으므로 PostgreSQL full-text search, `pg_trgm`, 검색용 정규화 컬럼 중 하나로 전환 검토.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.10.10
|
||||
|
||||
- 관리자 미디어 라이브러리에서 `*-body-720.webp`, `*-lightbox.webp` 파생 이미지가 원본 라이브러리에 섞이던 문제 수정.
|
||||
- 본문 파생 이미지 1600px·720px·2560px를 모두 원본 본문 이미지의 사용처와 연결해 미사용 일괄 삭제 대상에서 빠지도록 수정.
|
||||
- 미디어 상세의 본문 이미지 설명에 파생 이미지 해상도 라벨 추가.
|
||||
- 본문 파생 이미지 파일명 해상도형 전환 검토 과제를 `docs/todo.md`에 기록.
|
||||
|
||||
## v1.10.9
|
||||
|
||||
- 사이트 설정 POST 설정에 업로드 제한(1회 최대 파일 수·전체 용량) 관리 추가.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.9",
|
||||
"version": "1.10.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.9",
|
||||
"version": "1.10.10",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.9",
|
||||
"version": "1.10.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
normalizeUploadMaxFileCount,
|
||||
normalizeUploadMaxTotalSize
|
||||
} from '~/lib/upload-size-limit.js'
|
||||
import {
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl
|
||||
} from '~/lib/post-card-thumbnail-url.js'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
@@ -84,14 +88,33 @@ const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avat
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 게시물 카드 썸네일이면 true
|
||||
*/
|
||||
const isPostCardThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/posts/') && item?.url?.includes('/thumbs/') && item?.url?.endsWith('-card.webp'))
|
||||
const isPostCardThumbnailDiskItem = (item) => isPostCardThumbnailUrl(item?.url)
|
||||
|
||||
/**
|
||||
* 게시물 본문 표시용 이미지 디스크 경로 여부
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 게시물 본문 표시용 이미지이면 true
|
||||
*/
|
||||
const isPostBodyThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/posts/') && item?.url?.includes('/thumbs/') && item?.url?.endsWith('-body.webp'))
|
||||
const isPostBodyThumbnailDiskItem = (item) => isPostBodyThumbnailUrl(item?.url)
|
||||
|
||||
/**
|
||||
* 게시물 본문 파생 이미지 해상도 라벨을 반환한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {string} 해상도 라벨
|
||||
*/
|
||||
const getPostBodyThumbnailResolutionLabel = (item) => {
|
||||
const url = String(item?.url || '')
|
||||
|
||||
if (url.endsWith('-body-720.webp')) {
|
||||
return '720px'
|
||||
}
|
||||
|
||||
if (url.endsWith('-lightbox.webp')) {
|
||||
return '2560px'
|
||||
}
|
||||
|
||||
return '1600px'
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목이 시스템 파생 파일인지 확인한다.
|
||||
@@ -1545,7 +1568,7 @@ watch(filteredMediaUrls, (urls) => {
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">본문 이미지</dt>
|
||||
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
|
||||
<span v-if="selectedMedia.bodyThumbnailStatus.role === 'body'">
|
||||
자동 생성된 본문 표시용 WebP 이미지입니다.
|
||||
자동 생성된 본문 표시용 WebP 이미지입니다. ({{ getPostBodyThumbnailResolutionLabel(selectedMedia) }})
|
||||
</span>
|
||||
<template v-else>
|
||||
<span v-if="selectedMedia.bodyThumbnailStatus.hasThumbnail">
|
||||
|
||||
@@ -4,7 +4,9 @@ import { createError } from 'h3'
|
||||
import { getSiteSettings, listAdminPosts, listAdminTags, listPages } from '../repositories/content-repository'
|
||||
import { getPostgresClient } from '../repositories/postgres-client'
|
||||
import {
|
||||
getPostBodyPreviewThumbnailUrl,
|
||||
getPostBodyThumbnailUrl,
|
||||
getPostLightboxThumbnailUrl,
|
||||
getPostThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl,
|
||||
@@ -587,11 +589,31 @@ const buildPostThumbnailOwnerMap = (items) => buildPostDerivativeOwnerMap(
|
||||
* @param {Array<Object>} items - 미디어 항목 목록
|
||||
* @returns {Map<string, string>} 본문 표시용 이미지 URL별 원본 URL
|
||||
*/
|
||||
const buildPostBodyThumbnailOwnerMap = (items) => buildPostDerivativeOwnerMap(
|
||||
items,
|
||||
getPostBodyThumbnailUrl,
|
||||
isPostBodyThumbnailUrl
|
||||
)
|
||||
const buildPostBodyThumbnailOwnerMap = (items) => {
|
||||
const itemUrlSet = new Set(items.map((item) => item.url))
|
||||
const ownerByDerivativeUrl = new Map()
|
||||
const bodyDerivativeUrlGetters = [
|
||||
getPostBodyThumbnailUrl,
|
||||
getPostBodyPreviewThumbnailUrl,
|
||||
getPostLightboxThumbnailUrl
|
||||
]
|
||||
|
||||
for (const item of items) {
|
||||
if (isPostBodyThumbnailUrl(item.url) || isPostCardThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const getDerivativeUrl of bodyDerivativeUrlGetters) {
|
||||
const derivativeUrl = getDerivativeUrl(item.url)
|
||||
|
||||
if (derivativeUrl && itemUrlSet.has(derivativeUrl)) {
|
||||
ownerByDerivativeUrl.set(derivativeUrl, item.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ownerByDerivativeUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 카드 썸네일의 간접 사용처를 조회한다.
|
||||
|
||||
Reference in New Issue
Block a user