diff --git a/docs/changelog.md b/docs/changelog.md index f0bf1ab..4bae97d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.10.18 + +- 관리자 미디어 관리 화면이 필요한 페이지의 미디어만 받아오도록 바꿔, 파일이 많아질 때 목록 로딩 부담을 줄였다. +- 글쓰기 이미지 선택처럼 전체 원본 목록이 필요한 기존 화면은 그대로 동작하도록 호환을 유지했다. + ## v1.10.17 - 콜아웃 안에서도 굵게·링크 같은 인라인 문법이 보이도록 바꿨다. diff --git a/docs/history.md b/docs/history.md index 9340223..7bdc784 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-07-02 v1.10.18 — 미디어 관리는 API 응답부터 나눠 받는다 + +v1.10.16에서 관리자 미디어 화면 렌더링은 50·100·200개 단위로 나눴지만, API는 여전히 전체 미디어 배열을 내려줄 수 있었다. 미디어가 수백 개 이상으로 늘면 화면 렌더링을 나눠도 네트워크 payload와 클라이언트 메모리 부담이 남는다. 따라서 미디어 관리 화면은 `paged=1` 응답을 사용해 탭·폴더·종류·미사용·검색 조건이 적용된 현재 페이지 항목과 전체 개수 메타만 받는다. 글쓰기 이미지 선택과 대표 이미지 선택은 원본 전체 선택 흐름이 아직 필요하므로 기존 배열 응답을 유지해 호환성을 우선했다. + ## 2026-07-02 v1.10.17 — 콜아웃도 일반 문단과 같은 인라인 조합을 쓴다 콜아웃은 안내 문맥 안에서 게시물 내부 링크를 강조하는 경우가 많다. `**[링크](url)**`처럼 굵은 링크를 쓰면 기존 인라인 렌더러가 굵게 세그먼트 내부를 문자열로만 출력해 링크가 동작하지 않았다. 따라서 굵게·기울임·색상 세그먼트도 내부 인라인 문법을 재귀 렌더링하도록 맞추고, 콜아웃 라이브 편집 본문도 인용과 같은 멀티라인 인라인 렌더러를 사용하게 했다. diff --git a/docs/map.md b/docs/map.md index 323d5b4..c562941 100644 --- a/docs/map.md +++ b/docs/map.md @@ -194,7 +194,7 @@ | pages/admin/pages/index.vue | 페이지 목록, 제목 아래 주소·공개 보기 hover 아이콘, 홈 표시·섹션·추천 배지, 조회수·통계 모달, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) | | pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 홈 Pages 진열장 노출 메타, 저장 토스트 | | pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 홈 Pages 진열장 노출 메타, 설정 패널 삭제, 저장/삭제 토스트 | -| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/본문 이미지/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 라이브러리 파일 종류 필터와 미사용 토글, 시스템 탭 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, 50·100·200개 단위 페이지네이션과 현재 페이지 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·카드 썸네일·본문 이미지 사용 배지(파생 탭의 해당 배지는 숨김), 그리드용 작은 파생 이미지 우선 표시와 원본 fallback, 대표 이미지 원본 카드 썸네일 생성·재생성, 본문 이미지 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 | +| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/본문 이미지/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 라이브러리 파일 종류 필터와 미사용 토글, 시스템 탭 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, `/admin/api/media?paged=1` 기반 50·100·200개 단위 페이지네이션과 현재 페이지 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·카드 썸네일·본문 이미지 사용 배지(파생 탭의 해당 배지는 숨김), 그리드용 작은 파생 이미지 우선 표시와 원본 fallback, 대표 이미지 원본 카드 썸네일 생성·재생성, 본문 이미지 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 | | pages/admin/navigation/index.vue | 메뉴 관리: 메인 네비게이션/페이지 섹션/하단 네비게이션/추천 사이트 탭, 각 탭 작업줄 왼쪽 추가·오른쪽 저장 버튼, 메인·하단 메뉴 추가 모달, 페이지 섹션 추가 모달·수정·삭제·섹션 드래그 정렬·섹션별 카드형 페이지 순서 변경 모달(추천 우선 표시·섹션에서 제거 포함), 추천 사이트는 공개와 같은 카드 목록+편집 모달(전체 URL·썸네일·파비콘 갱신), 행 more vert(삭제), 저장 대상별 버튼(`네비게이션 저장`/`페이지 섹션 저장`), `useAdminToast` | | components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) | | composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 | @@ -284,7 +284,7 @@ | server/routes/admin/api/page-sections/[id].delete.js | 관리자 페이지 섹션 삭제 API | | server/routes/admin/api/page-sections/reorder.put.js | 관리자 페이지 섹션 순서 저장 API | | server/routes/admin/api/page-sections/[id]/pages/reorder.put.js | 관리자 페이지 섹션 내부 페이지 순서 저장 API | -| server/routes/admin/api/media.get.js | 관리자 미디어 목록 API | +| server/routes/admin/api/media.get.js | 관리자 미디어 목록 API. 기본 요청은 기존 호환용 전체 배열, `paged=1` 요청은 탭·폴더·종류·미사용·검색 필터와 페이지 메타가 포함된 미디어 관리 화면용 응답 | | server/routes/admin/api/media.put.js | 관리자 미디어 파일명 변경 및 단일/복수 폴더 변경 API | | server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API | | server/routes/admin/api/media/thumbnail.post.js | 원본 이미지의 게시물 목록 카드·본문 표시·Pages 커버 썸네일 생성·재생성 API | diff --git a/docs/spec.md b/docs/spec.md index 341ee81..5925c1e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -198,7 +198,7 @@ layouts/ - 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다. - 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 메인 태그를 드롭다운에서 추가할 수 있다. 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천하고, 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다. 게시물별 태그 최대 개수는 기본 5개이며, 사이트 설정 POST 설정의 `postTagLimit` 값으로 1~10개 범위에서 조절한다. - 관리자 글쓰기 상단 왼쪽 상태 영역은 `Published`, `Scheduled`, `Members`, `Private`, 초안 저장 상태 등 현재 상태 텍스트만 표시하며, 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서만 제공한다. 오른쪽 설정 패널 하단에는 본문 기준 단어 수, 공백 제외 문자 수와 공백 수, 예상 읽기 시간, 블록 수, 이미지 수를 작은 통계로 표시한다. -- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 미디어 목록은 화면 렌더링 기준 50·100·200개 단위 페이지네이션을 제공하며 번호와 이전·다음 버튼으로 이동한다. 현재 페이지 선택은 화면에 표시된 삭제 가능 항목만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다. +- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 미디어 관리 화면은 `/admin/api/media?paged=1` 응답을 사용해 서버에서 탭·폴더·파일 종류·미사용·검색 조건을 적용한 뒤 50·100·200개 단위 페이지네이션을 제공하며 번호와 이전·다음 버튼으로 이동한다. 현재 페이지 선택은 화면에 표시된 삭제 가능 항목만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다. - 메뉴 관리 항목은 `네비게이션`으로 표시한다. - 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다. - 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다. @@ -648,7 +648,8 @@ components/content/ - `DELETE /admin/api/page-sections/:id` - 미사용 페이지 섹션 삭제 - `PUT /admin/api/page-sections/reorder` - 페이지 섹션 표시 순서 저장 - `PUT /admin/api/page-sections/:id/pages/reorder` - 페이지 섹션 내부의 홈 표시 페이지 순서 저장. `removedPageIds`를 함께 보내면 해당 페이지의 섹션 연결을 해제 -- `GET /admin/api/media` - 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 `avatarOwner` 요약이 붙을 수 있음) +- `GET /admin/api/media` - 업로드 미디어 전체 배열(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 `avatarOwner` 요약이 붙을 수 있음). 글쓰기·대표 이미지 선택 등 기존 선택 모달 호환용으로 유지한다. +- `GET /admin/api/media?paged=1` - 관리자 미디어 관리 화면용 페이지 응답. `tab`, `page`, `pageSize`, `search`, `folder`, `kind`, `unused` 쿼리를 받아 `{ items, pagination, counts }`를 반환한다. `pageSize`는 50·100·200 중 하나를 사용한다. - `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을 참조할 때만 거부) @@ -922,7 +923,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`와 `*-page-cover.webp` 파일만, 본문 이미지 탭에서는 `/posts/YYYY/MM/thumbs/*-body-1600.webp`, `*-body-720.webp`, `*-body-2560.webp` 파일과 레거시 `*-body.webp`, `*-lightbox.webp` 파일만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. 본문 이미지 탭의 상세 정보는 1600px, 720px, 2560px 용도를 구분해 표시한다. 라이브러리 탭의 파일 종류 필터는 `전체/이미지/영상/음악/파일`을 표시하고 사용 상태 필터는 중복 `전체` 배지를 피하기 위해 `미사용` 토글만 표시한다. 시스템 파생 탭에서는 전체/미사용 배지형 필터를 표시하고, 목록 카드 오른쪽 위에는 실제 사용 개수 숫자 배지를 표시한다. 카드 썸네일 탭과 본문 이미지 탭에서는 모든 항목이 해당 파생 파일이므로 각각 `카드`, `본문` 배지는 표시하지 않는다. 미디어 그리드는 50·100·200개 단위 페이지네이션을 사용하며, 원본 이미지 항목도 가능한 경우 720px 본문 파생 이미지 또는 카드·Pages 커버 썸네일을 우선 표시하고 실패 시 원본으로 되돌린다. 파생 이미지 목록·상세 미리보기는 파일 수정 시각과 용량을 캐시 버전으로 붙여 재생성 직후에도 새 파일을 확인할 수 있게 한다. +- 관리자 미디어 화면 상단에 **미디어 라이브러리**·**카드 썸네일**·**본문 이미지**·**프로필 이미지** 탭을 두어, 라이브러리 탭에서는 원본 업로드 파일만, 카드 썸네일 탭에서는 `/posts/YYYY/MM/thumbs/*-card.webp`와 `*-page-cover.webp` 파일만, 본문 이미지 탭에서는 `/posts/YYYY/MM/thumbs/*-body-1600.webp`, `*-body-720.webp`, `*-body-2560.webp` 파일과 레거시 `*-body.webp`, `*-lightbox.webp` 파일만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. 본문 이미지 탭의 상세 정보는 1600px, 720px, 2560px 용도를 구분해 표시한다. 라이브러리 탭의 파일 종류 필터는 `전체/이미지/영상/음악/파일`을 표시하고 사용 상태 필터는 중복 `전체` 배지를 피하기 위해 `미사용` 토글만 표시한다. 시스템 파생 탭에서는 전체/미사용 배지형 필터를 표시하고, 목록 카드 오른쪽 위에는 실제 사용 개수 숫자 배지를 표시한다. 카드 썸네일 탭과 본문 이미지 탭에서는 모든 항목이 해당 파생 파일이므로 각각 `카드`, `본문` 배지는 표시하지 않는다. 미디어 그리드는 서버 페이지 응답 기준 50·100·200개 단위 페이지네이션을 사용하며, 원본 이미지 항목도 가능한 경우 720px 본문 파생 이미지 또는 카드·Pages 커버 썸네일을 우선 표시하고 실패 시 원본으로 되돌린다. 파생 이미지 목록·상세 미리보기는 파일 수정 시각과 용량을 캐시 버전으로 붙여 재생성 직후에도 새 파일을 확인할 수 있게 한다. - 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다. - 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다. - 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 현재 페이지 안의 범위 선택이 가능하다. 현재 페이지 선택은 화면에 보이는 항목 중 사용 중이 아니어서 삭제 가능한 항목만 대상으로 한다. diff --git a/docs/update.md b/docs/update.md index 1389197..45a041d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.10.18 + +- 관리자 미디어 관리 화면이 `/admin/api/media?paged=1` 페이지 응답을 사용하도록 수정. +- 관리자 미디어 API에 탭·폴더·종류·미사용·검색 필터와 50·100·200개 페이지네이션 메타 응답 추가. +- 글쓰기·대표 이미지 선택 등 기존 미디어 배열 응답 사용처는 기존 `/admin/api/media` 배열 응답을 유지하도록 호환 처리. + ## v1.10.17 - 콜아웃 라이브 편집 본문에서도 굵게·링크 등 인라인 마크다운이 표시되도록 수정. diff --git a/package-lock.json b/package-lock.json index de5a81c..8de065c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.10.17", + "version": "1.10.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.10.17", + "version": "1.10.18", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 63c2919..5d6428b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.17", + "version": "1.10.18", "private": true, "type": "module", "imports": { diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index e73f0bb..fbac433 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -80,10 +80,82 @@ const { openMenuId, closeMenu } = useAdminRowMenu() */ const getFolderMenuId = (folder) => `folder:${folder}` -const { data: mediaItems, refresh } = await useFetch('/admin/api/media', { - default: () => [] +const mediaQuery = computed(() => ({ + paged: '1', + tab: activeTab.value, + page: mediaPage.value, + pageSize: mediaPageSize.value, + search: searchText.value.trim() || undefined, + folder: activeFolder.value || undefined, + kind: activeMediaKind.value, + unused: showUnusedOnly.value ? '1' : undefined +})) + +const { data: mediaResponse, refresh } = await useFetch('/admin/api/media', { + query: mediaQuery, + default: () => ({ + items: [], + pagination: { + page: 1, + pageSize: 50, + total: 0, + pageCount: 1 + }, + counts: { + tabs: { + library: 0, + postThumbnails: 0, + postBodyThumbnails: 0, + thumbnails: 0 + }, + folders: {}, + kinds: { + all: 0, + image: 0, + video: 0, + audio: 0, + file: 0 + }, + usage: { + all: 0, + unused: 0 + } + } + }) }) +const mediaItems = computed(() => Array.isArray(mediaResponse.value) + ? mediaResponse.value + : (mediaResponse.value?.items || [])) + +const mediaPagination = computed(() => Array.isArray(mediaResponse.value) + ? { + page: 1, + pageSize: mediaPageSize.value, + total: mediaItems.value.length, + pageCount: Math.max(1, Math.ceil(mediaItems.value.length / mediaPageSize.value)) + } + : (mediaResponse.value?.pagination || { + page: 1, + pageSize: mediaPageSize.value, + total: 0, + pageCount: 1 + })) + +const mediaCounts = computed(() => Array.isArray(mediaResponse.value) + ? { + tabs: {}, + folders: {}, + kinds: {}, + usage: {} + } + : (mediaResponse.value?.counts || { + tabs: {}, + folders: {}, + kinds: {}, + usage: {} + })) + /** * 썸네일 디스크 경로 여부 * @param {Object} item - 미디어 항목 @@ -338,42 +410,18 @@ const canRegeneratePostBodyThumbnail = (item) => ( */ const getMediaItemKind = (item) => item?.kind || 'image' -const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item) && !isPostCardThumbnailDiskItem(item) && !isPageCoverThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item))) - -const postThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostCardThumbnailDiskItem(item) || isPageCoverThumbnailDiskItem(item))) - -const postBodyThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostBodyThumbnailDiskItem(item))) - -const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isThumbnailDiskItem(item))) - -const scopeItems = computed(() => { - if (activeTab.value === 'thumbnails') { - return thumbnailMediaItems.value - } - - if (activeTab.value === 'postThumbnails') { - return postThumbnailMediaItems.value - } - - if (activeTab.value === 'postBodyThumbnails') { - return postBodyThumbnailMediaItems.value - } - - return libraryMediaItems.value -}) - const systemManagedTab = computed(() => ['thumbnails', 'postThumbnails', 'postBodyThumbnails'].includes(activeTab.value)) const activeSystemMediaCount = computed(() => { if (activeTab.value === 'postThumbnails') { - return postThumbnailMediaItems.value.length + return mediaCounts.value.tabs?.postThumbnails || 0 } if (activeTab.value === 'postBodyThumbnails') { - return postBodyThumbnailMediaItems.value.length + return mediaCounts.value.tabs?.postBodyThumbnails || 0 } - return thumbnailMediaItems.value.length + return mediaCounts.value.tabs?.thumbnails || 0 }) const activeSystemMediaTitle = computed(() => { @@ -401,21 +449,10 @@ const activeSystemMediaHint = computed(() => { }) const mediaKindFilterOptions = computed(() => { - const baseItems = scopeItems.value.filter((item) => { - const folder = activeFolder.value - - return systemManagedTab.value - ? true - : (!folder || item.category === folder || item.category?.startsWith(`${folder}/`)) - }) - const countByKind = baseItems.reduce((counts, item) => { - const kind = getMediaItemKind(item) - counts[kind] = (counts[kind] || 0) + 1 - return counts - }, {}) + const countByKind = mediaCounts.value.kinds || {} return [ - { id: 'all', label: '전체', count: baseItems.length }, + { id: 'all', label: '전체', count: countByKind.all || 0 }, { id: 'image', label: '이미지', count: countByKind.image || 0 }, { id: 'video', label: '영상', count: countByKind.video || 0 }, { id: 'audio', label: '음악', count: countByKind.audio || 0 }, @@ -424,20 +461,11 @@ const mediaKindFilterOptions = computed(() => { }) const mediaUsageFilterOptions = computed(() => { - const folder = activeFolder.value - const mediaKind = activeMediaKind.value - const baseItems = scopeItems.value.filter((item) => { - const matchesFolder = systemManagedTab.value - ? true - : (!folder || item.category === folder || item.category?.startsWith(`${folder}/`)) - const matchesKind = mediaKind === 'all' || getMediaItemKind(item) === mediaKind - - return matchesFolder && matchesKind - }) + const usageCounts = mediaCounts.value.usage || {} return [ - { id: 'all', label: '전체', count: baseItems.length, unusedOnly: false }, - { id: 'unused', label: '미사용', count: baseItems.filter((item) => !isMediaItemLocked(item)).length, unusedOnly: true } + { id: 'all', label: '전체', count: usageCounts.all || 0, unusedOnly: false }, + { id: 'unused', label: '미사용', count: usageCounts.unused || 0, unusedOnly: true } ] }) @@ -542,6 +570,7 @@ const selectedMedia = computed(() => mediaItems.value.find((item) => item.url == const normalizedFolders = computed(() => { const folderSet = new Set(['미분류']) + const folderCounts = mediaCounts.value.folders || {} mediaFolders.value.forEach((folder) => { String(folder || '').split('/').filter(Boolean).reduce((parentPath, segment) => { @@ -551,8 +580,8 @@ const normalizedFolders = computed(() => { }, '') }) - libraryMediaItems.value.forEach((item) => { - String(item.category || '미분류').split('/').filter(Boolean).reduce((parentPath, segment) => { + Object.keys(folderCounts).forEach((folder) => { + String(folder || '미분류').split('/').filter(Boolean).reduce((parentPath, segment) => { const nextPath = parentPath ? `${parentPath}/${segment}` : segment folderSet.add(nextPath) return nextPath @@ -563,46 +592,27 @@ const normalizedFolders = computed(() => { }) const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts, folder) => { - counts[folder] = libraryMediaItems.value.filter((item) => item.category === folder || item.category?.startsWith(`${folder}/`)).length + counts[folder] = mediaCounts.value.folders?.[folder] || 0 return counts }, {})) -const filteredMediaItems = computed(() => { - const query = searchText.value.trim().toLowerCase() - const folder = activeFolder.value - const mediaKind = activeMediaKind.value - const unusedOnly = showUnusedOnly.value - const base = scopeItems.value - - return base.filter((item) => { - const matchesFolder = systemManagedTab.value - ? true - : (!folder || item.category === folder || item.category?.startsWith(`${folder}/`)) - const usageTitles = item.usage?.map((usage) => usage.title) || [] - const matchesQuery = !query || [ - item.name, - ...usageTitles - ].some((value) => String(value || '').toLowerCase().includes(query)) - const matchesKind = mediaKind === 'all' || getMediaItemKind(item) === mediaKind - const matchesUsage = !unusedOnly || !isMediaItemLocked(item) - - return matchesFolder && matchesQuery && matchesKind && matchesUsage - }) -}) +const filteredMediaItems = computed(() => mediaItems.value) const filteredMediaUrls = computed(() => filteredMediaItems.value.map((item) => item.url)) -const mediaPageCount = computed(() => Math.max(1, Math.ceil(filteredMediaItems.value.length / mediaPageSize.value))) +const mediaPageCount = computed(() => Math.max(1, Number(mediaPagination.value.pageCount) || 1)) -const mediaPageStartIndex = computed(() => filteredMediaItems.value.length - ? (mediaPage.value - 1) * mediaPageSize.value +const mediaPageTotal = computed(() => Number(mediaPagination.value.total) || 0) + +const mediaPageStartIndex = computed(() => mediaPageTotal.value + ? ((Number(mediaPagination.value.page) || mediaPage.value) - 1) * mediaPageSize.value : 0) -const mediaPageDisplayStart = computed(() => filteredMediaItems.value.length ? mediaPageStartIndex.value + 1 : 0) +const mediaPageDisplayStart = computed(() => mediaPageTotal.value ? mediaPageStartIndex.value + 1 : 0) -const mediaPageEndIndex = computed(() => Math.min(mediaPageStartIndex.value + mediaPageSize.value, filteredMediaItems.value.length)) +const mediaPageEndIndex = computed(() => Math.min(mediaPageStartIndex.value + filteredMediaItems.value.length, mediaPageTotal.value)) -const paginatedMediaItems = computed(() => filteredMediaItems.value.slice(mediaPageStartIndex.value, mediaPageEndIndex.value)) +const paginatedMediaItems = computed(() => filteredMediaItems.value) const filteredSelectableMediaItems = computed(() => paginatedMediaItems.value.filter((item) => !isMediaItemLocked(item))) @@ -660,7 +670,8 @@ const mediaPaginationItems = computed(() => { */ const setMediaPage = (page) => { mediaPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPageCount.value) - lastSelectedIndex.value = -1 + clearMediaSelection() + closeMediaDetail() } /** @@ -671,7 +682,8 @@ const setMediaPage = (page) => { const setMediaPageSize = (event) => { mediaPageSize.value = Number(event.target.value) || 50 mediaPage.value = 1 - lastSelectedIndex.value = -1 + clearMediaSelection() + closeMediaDetail() } const selectedDeletableMediaItems = computed(() => { @@ -1300,13 +1312,23 @@ watch(filteredMediaUrls, (urls) => { watch([activeTab, activeFolder, activeMediaKind, showUnusedOnly, searchText], () => { mediaPage.value = 1 - lastSelectedIndex.value = -1 + clearMediaSelection() + closeMediaDetail() }) watch(mediaPageCount, (pageCount) => { if (mediaPage.value > pageCount) { mediaPage.value = pageCount - lastSelectedIndex.value = -1 + clearMediaSelection() + closeMediaDetail() + } +}) + +watch(() => mediaPagination.value.page, (page) => { + const normalizedPage = Number(page) || 1 + + if (normalizedPage !== mediaPage.value) { + mediaPage.value = normalizedPage } }) @@ -1432,7 +1454,7 @@ watch(mediaPageCount, (pageCount) => { @drop.prevent="dropMediaOnFolder('미분류')" > 전체 미디어 - {{ libraryMediaItems.length }} + {{ mediaCounts.tabs?.library || 0 }}
@@ -1511,7 +1533,7 @@ watch(mediaPageCount, (pageCount) => { {{ systemManagedTab ? activeSystemMediaTitle : (activeFolder || '전체 미디어') }}

- {{ filteredMediaItems.length }}개 중 {{ mediaPageDisplayStart }}-{{ mediaPageEndIndex }}개 표시 + {{ mediaPageTotal }}개 중 {{ mediaPageDisplayStart }}-{{ mediaPageEndIndex }}개 표시

diff --git a/server/routes/admin/api/media.get.js b/server/routes/admin/api/media.get.js index 2a68062..748ace7 100644 --- a/server/routes/admin/api/media.get.js +++ b/server/routes/admin/api/media.get.js @@ -1,13 +1,239 @@ import { requireAdminSession } from '../../../utils/admin-auth' import { listMediaItems } from '../../../utils/media-library' +import { + isPageCoverThumbnailUrl, + isPostBodyThumbnailUrl, + isPostCardThumbnailUrl +} from '../../../../lib/post-card-thumbnail-url.js' + +/** 관리자 미디어 페이지 표시 개수 허용값 */ +const mediaPageSizeOptions = [50, 100, 200] + +/** + * 미디어 항목 종류를 반환한다. + * @param {Object} item - 미디어 항목 + * @returns {'image'|'video'|'audio'|'file'} 미디어 종류 + */ +const getMediaItemKind = (item) => item?.kind || 'image' + +/** + * 미디어 항목 잠금 여부를 반환한다. + * @param {Object} item - 미디어 항목 + * @returns {boolean} 잠금 여부 + */ +const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner) + +/** + * 프로필 이미지 파일인지 확인한다. + * @param {Object} item - 미디어 항목 + * @returns {boolean} 프로필 이미지이면 true + */ +const isProfileThumbnailItem = (item) => Boolean(item?.url?.includes('/members/avatars/')) + +/** + * 관리자 미디어 탭 id를 정규화한다. + * @param {unknown} value - 요청 탭 + * @returns {'library'|'postThumbnails'|'postBodyThumbnails'|'thumbnails'} 탭 id + */ +const normalizeMediaTab = (value) => { + const tab = String(value || 'library') + + return ['library', 'postThumbnails', 'postBodyThumbnails', 'thumbnails'].includes(tab) + ? tab + : 'library' +} + +/** + * 관리자 미디어 종류 필터를 정규화한다. + * @param {unknown} value - 요청 미디어 종류 + * @returns {'all'|'image'|'video'|'audio'|'file'} 미디어 종류 + */ +const normalizeMediaKind = (value) => { + const kind = String(value || 'all') + + return ['all', 'image', 'video', 'audio', 'file'].includes(kind) + ? kind + : 'all' +} + +/** + * 관리자 미디어 페이지 표시 개수를 정규화한다. + * @param {unknown} value - 요청 표시 개수 + * @returns {number} 표시 개수 + */ +const normalizeMediaPageSize = (value) => { + const pageSize = Number(value) || 50 + + return mediaPageSizeOptions.includes(pageSize) ? pageSize : 50 +} + +/** + * 관리자 미디어 탭별 항목인지 확인한다. + * @param {Object} item - 미디어 항목 + * @param {'library'|'postThumbnails'|'postBodyThumbnails'|'thumbnails'} tab - 탭 id + * @returns {boolean} 탭에 속하면 true + */ +const matchesMediaTab = (item, tab) => { + const url = item?.url + + if (tab === 'thumbnails') { + return isProfileThumbnailItem(item) + } + + if (tab === 'postThumbnails') { + return isPostCardThumbnailUrl(url) || isPageCoverThumbnailUrl(url) + } + + if (tab === 'postBodyThumbnails') { + return isPostBodyThumbnailUrl(url) + } + + return !isProfileThumbnailItem(item) + && !isPostCardThumbnailUrl(url) + && !isPageCoverThumbnailUrl(url) + && !isPostBodyThumbnailUrl(url) +} + +/** + * 폴더와 하위 폴더에 포함되는지 확인한다. + * @param {Object} item - 미디어 항목 + * @param {string} folder - 폴더 경로 + * @returns {boolean} 포함 여부 + */ +const matchesMediaFolder = (item, folder) => !folder + || item.category === folder + || item.category?.startsWith(`${folder}/`) + +/** + * 검색어와 미디어 항목이 일치하는지 확인한다. + * @param {Object} item - 미디어 항목 + * @param {string} query - 소문자 검색어 + * @returns {boolean} 일치 여부 + */ +const matchesMediaQuery = (item, query) => { + if (!query) { + return true + } + + const usageTitles = Array.isArray(item?.usage) + ? item.usage.map((usage) => usage.title) + : [] + + return [ + item?.name, + item?.title, + ...usageTitles + ].some((value) => String(value || '').toLowerCase().includes(query)) +} + +/** + * 미디어 폴더별 개수를 계산한다. + * @param {Array} items - 미디어 항목 목록 + * @returns {Record} 폴더별 개수 + */ +const countMediaFolders = (items) => items.reduce((counts, item) => { + const segments = String(item.category || '미분류').split('/').filter(Boolean) + + if (!segments.length) { + counts['미분류'] = (counts['미분류'] || 0) + 1 + return counts + } + + segments.reduce((parentPath, segment) => { + const folder = parentPath ? `${parentPath}/${segment}` : segment + counts[folder] = (counts[folder] || 0) + 1 + return folder + }, '') + + return counts +}, {}) + +/** + * 미디어 종류별 개수를 계산한다. + * @param {Array} items - 미디어 항목 목록 + * @returns {Record} 종류별 개수 + */ +const countMediaKinds = (items) => items.reduce((counts, item) => { + const kind = getMediaItemKind(item) + counts[kind] = (counts[kind] || 0) + 1 + counts.all += 1 + return counts +}, { + all: 0, + image: 0, + video: 0, + audio: 0, + file: 0 +}) + +/** + * 미디어 페이지 응답을 구성한다. + * @param {Array} items - 전체 미디어 목록 + * @param {Record} query - 요청 쿼리 + * @returns {Object} 페이지 미디어 응답 + */ +const createPagedMediaResponse = (items, query) => { + const tab = normalizeMediaTab(query.tab) + const pageSize = normalizeMediaPageSize(query.pageSize) + const requestedPage = Math.max(1, Number(query.page) || 1) + const mediaKind = normalizeMediaKind(query.kind) + const searchQuery = String(query.search || '').trim().toLowerCase() + const folder = tab === 'library' ? String(query.folder || '').trim() : '' + const unusedOnly = query.unused === '1' || query.unused === 'true' + + const libraryItems = items.filter((item) => matchesMediaTab(item, 'library')) + const tabItems = items.filter((item) => matchesMediaTab(item, tab)) + const folderItems = tab === 'library' + ? tabItems.filter((item) => matchesMediaFolder(item, folder)) + : tabItems + const kindItems = folderItems.filter((item) => mediaKind === 'all' || getMediaItemKind(item) === mediaKind) + const usageFilteredItems = unusedOnly + ? kindItems.filter((item) => !isMediaItemLocked(item)) + : kindItems + const filteredItems = usageFilteredItems.filter((item) => matchesMediaQuery(item, searchQuery)) + const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize)) + const page = Math.min(requestedPage, pageCount) + const startIndex = (page - 1) * pageSize + + return { + items: filteredItems.slice(startIndex, startIndex + pageSize), + pagination: { + page, + pageSize, + total: filteredItems.length, + pageCount + }, + counts: { + tabs: { + library: libraryItems.length, + postThumbnails: items.filter((item) => matchesMediaTab(item, 'postThumbnails')).length, + postBodyThumbnails: items.filter((item) => matchesMediaTab(item, 'postBodyThumbnails')).length, + thumbnails: items.filter((item) => matchesMediaTab(item, 'thumbnails')).length + }, + folders: countMediaFolders(libraryItems), + kinds: countMediaKinds(folderItems), + usage: { + all: kindItems.length, + unused: kindItems.filter((item) => !isMediaItemLocked(item)).length + } + } + } +} /** * 관리자 미디어 목록 API * @param {import('h3').H3Event} event - 요청 이벤트 - * @returns {Promise>} 미디어 목록 + * @returns {Promise|Object>} 미디어 목록 또는 페이지 응답 */ export default defineEventHandler(async (event) => { requireAdminSession(event) - return listMediaItems() + const items = await listMediaItems() + const query = getQuery(event) + + if (query.paged === '1' || query.paged === 'true') { + return createPagedMediaResponse(items, query) + } + + return items })