관리자 미디어 라이브러리·썸네일 탭 분리 및 논리 폴더 정책(v0.0.90)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 10:40:27 +09:00
parent 05176609ee
commit 21024602b0
10 changed files with 488 additions and 95 deletions

View File

@@ -0,0 +1,12 @@
-- 게시물 업로드 경로 기본 분류(posts) 및 구 프로필 경로(회원/썸네일)를 논리 폴더 정책에 맞게 정리한다.
UPDATE media_metadata
SET
category = '미분류',
updated_at = now()
WHERE category = 'posts';
UPDATE media_metadata
SET
category = '썸네일',
updated_at = now()
WHERE category = '회원/썸네일';

View File

@@ -1,5 +1,11 @@
# 의사결정 이력 # 의사결정 이력
## 2026-05-12 v0.0.90
### 관리자 미디어 라이브러리와 썸네일 탭 분리
게시물 이미지는 디스크상 `posts/`에 두더라도 논리 분류는 `미분류`로 통일해 `posts` 트리와 이중 표기를 없앤다. 회원 프로필 이미지는 디스크 경로는 유지하되 논리 폴더를 예약명 `썸네일`로 고정하고, 관리자 화면에서는 탭을 나눠 검색·탐색 대상을 분리했다. 썸네일 파일은 URL이 회원 콘텐츠와 직결되므로 관리자에서 임의 삭제·이름 변경이 되면 프로필이 깨지기 쉬워 API·UI에서 막는다.
## 2026-05-12 v0.0.89 ## 2026-05-12 v0.0.89
### 미디어 선택 토글 가시성 ### 미디어 선택 토글 가시성

View File

@@ -81,7 +81,7 @@
| pages/admin/pages/index.vue | 페이지 목록 | | pages/admin/pages/index.vue | 페이지 목록 |
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 | | pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 | | pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, 폴더 트리·폴더 추가 모달·폴더 삭제, 썸네일 클릭 미리보기 모달, 좌상단 선택 토글·Shift 범위, 드래그로 폴더 이동 | | pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 상단 탭, 라이브러리: 폴더 트리·폴더 추가 모달·폴더 삭제·드래그 이동·썸네일 탭: 회원 아바타만·검색(닉네임 등), 썸네일 클릭 미리보기 모달(연결 회원 블록), 좌상단 선택 토글·Shift 범위 |
| pages/admin/navigation/index.vue | 메뉴/네비게이션 관리(변경 시에만 메뉴 저장 활성) | | pages/admin/navigation/index.vue | 메뉴/네비게이션 관리(변경 시에만 메뉴 저장 활성) |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 | | pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
| pages/admin/tags/new.vue | 태그 생성 | | pages/admin/tags/new.vue | 태그 생성 |
@@ -124,7 +124,7 @@
| server/api/auth/logout.post.js | 회원 로그아웃 API | | server/api/auth/logout.post.js | 회원 로그아웃 API |
| server/api/auth/profile.get.js | 회원 프로필 조회 API | | server/api/auth/profile.get.js | 회원 프로필 조회 API |
| server/api/auth/profile.put.js | 회원 프로필 수정 API | | server/api/auth/profile.put.js | 회원 프로필 수정 API |
| server/api/auth/avatar.post.js | 회원 썸네일 업로드 API(WebP 변환, 최소 해상도 검증, 중앙 1:1 강제 크롭, 품질 보정) | | server/api/auth/avatar.post.js | 회원 썸네일 업로드 API(WebP 변환, 최소 해상도 검증, 중앙 1:1 강제 크롭, 품질 보정, `media_metadata` 논리 폴더 `썸네일`) |
| server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API | | server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API |
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API | | server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
| server/api/auth/password.put.js | 회원 비밀번호 변경 API | | server/api/auth/password.put.js | 회원 비밀번호 변경 API |
@@ -150,7 +150,7 @@
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API | | server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API | | server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API | | server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API | | server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API(성공 시 `media_metadata``미분류`로 기록) |
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) | | server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API | | server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API | | server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
@@ -175,7 +175,7 @@
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 | | server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
| server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 | | server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 |
| server/utils/navigation-items.js | 네비게이션 기본값과 그룹 유틸리티 | | server/utils/navigation-items.js | 네비게이션 기본값과 그룹 유틸리티 |
| server/utils/media-library.js | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티(회원 썸네일 포함, `회원/썸네일` 카테고리 노출) | | server/utils/media-library.js | 업로드 미디어·논리 폴더(`미분류`, 예약 `썸네일``avatarOwner` 부착·아바타 삭제/이름변경 차단 |
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 | | server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 | | server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
| server/repositories/member-repository.js | 회원 조회/생성 저장소 | | server/repositories/member-repository.js | 회원 조회/생성 저장소 |
@@ -192,6 +192,7 @@
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 | | db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 | | db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 | | db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
| db/migrations/016_media_category_normalize.sql | `media_metadata` 레거시 `posts``미분류`, `회원/썸네일``썸네일` 정리 |
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 | | db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
| db/migrations/009_add_post_og_image.sql | 게시물 OG 이미지 필드 추가 | | db/migrations/009_add_post_og_image.sql | 게시물 OG 이미지 필드 추가 |
| db/migrations/010_add_members_and_comments.sql | 회원/댓글 테이블 추가 | | db/migrations/010_add_members_and_comments.sql | 회원/댓글 테이블 추가 |

View File

@@ -300,7 +300,7 @@ components/content/
| 필드 | 타입 | 설명 | | 필드 | 타입 | 설명 |
|------|------|------| |------|------|------|
| url | String | 업로드 미디어 URL | | url | String | 업로드 미디어 URL |
| category | String | 관리자 미디어 폴더 경로 | | category | String | 논리 폴더 경로(게시물 업로드 이미지는 `미분류`, 회원 아바타는 예약값 `썸네일` 등) |
| created_at | DateTime | 생성일 | | created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 | | updated_at | DateTime | 수정일 |
@@ -366,7 +366,7 @@ components/content/
> 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다. > 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다.
> `AVATAR_WEBP_QUALITY`는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다. > `AVATAR_WEBP_QUALITY`는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다.
> 회원 썸네일을 새로 업로드하거나 제거/탈퇴할 때 기존 회원 썸네일 파일과 메타데이터는 자동 정리한다. > 회원 썸네일을 새로 업로드하거나 제거/탈퇴할 때 기존 회원 썸네일 파일과 메타데이터는 자동 정리한다.
> 관리자 미디어 화면에서 회원 썸네일도 `회원/썸네일` 폴더로 조회 가능하다. > 관리자 미디어 화면의 **썸네일** 탭에서 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
### 관리자 API (`/admin/api/`) ### 관리자 API (`/admin/api/`)
@@ -383,9 +383,9 @@ components/content/
- `GET /admin/api/pages/:id` - 고정 페이지 상세 - `GET /admin/api/pages/:id` - 고정 페이지 상세
- `PUT /admin/api/pages/:id` - 고정 페이지 수정 - `PUT /admin/api/pages/:id` - 고정 페이지 수정
- `DELETE /admin/api/pages/:id` - 고정 페이지 삭제 - `DELETE /admin/api/pages/:id` - 고정 페이지 삭제
- `GET /admin/api/media` - 업로드 미디어 목록 - `GET /admin/api/media` - 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 `avatarOwner` 요약이 붙을 수 있음)
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경 - `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더 `썸네일`만 허용, 일반 미디어를 `썸네일`로 옮기는 것은 거부)
- `DELETE /admin/api/media` - 업로드 미디어 삭제 - `DELETE /admin/api/media` - 업로드 미디어 삭제(회원 아바타 디스크 경로 파일은 거부)
- `GET /admin/api/media-folders` - 미디어 폴더 목록 - `GET /admin/api/media-folders` - 미디어 폴더 목록
- `POST /admin/api/media-folders` - 미디어 폴더 생성 - `POST /admin/api/media-folders` - 미디어 폴더 생성
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata``미분류`로 되돌림) - `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata``미분류`로 되돌림)
@@ -559,23 +559,25 @@ components/content/
/uploads/system/favicon.png /uploads/system/favicon.png
``` ```
- 디스크 상대 경로의 **첫 번째 디렉터리 이름**(`posts`, `pages`, `members` 등)이 `media_metadata`가 없을 때 미디어 라이브러리에 보이는 **기본 폴더(논리 분류)**가 된다. 관리자 에디터 업로드 API는 현재 `public/uploads/posts/YYYY/MM/`에만 저장하므로, 메타가 없는 새 이미지는 기본적으로 **`posts`** 트리 아래로 잡힌다. - 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음).
- `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category``미분류`로 저장된 항목이 여기에 모인다. 디스크 경로가 `posts/...`인데도 화면에 `미분류`로 보이려면 메타가 `미분류`로 저장돼 있어야 한다. - `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category``미분류`로 저장된 항목이 여기에 모인다.
- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 업로드 시 서버가 `media_metadata` **`회원/썸네일`** 카테고리를 넣어 미디어 라이브러리에서 회원 영역으로 묶인다(자동으로 `미분류`에 들어가지 않는다). - 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다.
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다. - 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다. - 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다. - 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
- 관리자 미디어 화면은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다. - 관리자 미디어 화면 상단에 **미디어 라이브러리** 탭과 **썸네일** 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 썸네일 탭에서는 `/members/avatars/` 파일만 검색·탐색한다.
- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`를 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다. - 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다.
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다. - 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다.
- 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다. - 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 상세 모달에서 표시한다. - 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다. - 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다. - 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다. - 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다. - 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다. - 회원 프로필 썸네일 파일은 관리자 화면에서 파일명 변경·삭제를 차단한다.
--- ---

View File

@@ -1,9 +1,16 @@
# 업데이트 이력 # 업데이트 이력
## v0.0.90
- 관리자 미디어: 상단 탭으로 **미디어 라이브러리**와 **썸네일**(회원 `/members/avatars/`만) 분리, 썸네일 검색에 닉네임·이메일·IP 반영.
- 게시물용 관리자 업로드는 디스크 `posts/연/월` 유지하되 `media_metadata` 및 목록 논리 폴더는 **`미분류`**로 통일; 회원 아바타 메타는 **`썸네일`**.
- `listMediaItems``avatarOwner` 부착, 썸네일 파일의 관리자 삭제·이름 변경·임의 논리 폴더 이동 차단, 예약 폴더 `썸네일``media_folders` 생성·삭제 차단.
- 마이그레이션 `016_media_category_normalize.sql`로 레거시 `posts`/`회원/썸네일` 카테고리 문자열 정리.
## v0.0.89 ## v0.0.89
- 관리자 미디어 썸네일 선택 컨트롤을 네이티브 체크박스에서 대비가 분명한 토글 버튼(미선택: 흰 배경·진한 테두리, 선택: 진한 배경·흰 체크)으로 교체. - 관리자 미디어 썸네일 선택 컨트롤을 네이티브 체크박스에서 대비가 분명한 토글 버튼(미선택: 흰 배경·진한 테두리, 선택: 진한 배경·흰 체크)으로 교체.
- `docs/spec.md``posts`·`미분류`·`회원/썸네일` 디스크 경로·`media_metadata` 관계를 명시. - `docs/spec.md`미디어 디스크 경로`media_metadata` 논리 폴더 관계를 명시(이후 v0.0.90에서 `미분류`/`썸네일` 정책으로 갱신).
## v0.0.88 ## v0.0.88

View File

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

View File

@@ -3,6 +3,17 @@ definePageMeta({
layout: 'admin' layout: 'admin'
}) })
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
const MEDIA_THUMBNAIL_ROOT = '썸네일'
/**
* 논리 폴더 경로가 썸네일 전용 루트인지 확인한다.
* @param {string} folder - 폴더 경로
* @returns {boolean} 썸네일 전용이면 true
*/
const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || String(folder).startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)
const activeTab = ref('library')
const searchText = ref('') const searchText = ref('')
const activeFolder = ref('') const activeFolder = ref('')
const isCreateFolderModalOpen = ref(false) const isCreateFolderModalOpen = ref(false)
@@ -22,6 +33,63 @@ const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
default: () => [] default: () => []
}) })
/**
* 썸네일 디스크 경로 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 회원 아바타 경로이면 true
*/
const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avatars/'))
/**
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 잠금이면 true
*/
const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner)
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item)))
const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isThumbnailDiskItem(item)))
const scopeItems = computed(() => (activeTab.value === 'thumbnails' ? thumbnailMediaItems.value : libraryMediaItems.value))
/**
* 상단 탭 전환 시 목록 상태를 초기화한다.
* @param {'library' | 'thumbnails'} tab - 선택 탭
* @returns {void}
*/
const setActiveTab = (tab) => {
if (activeTab.value === tab) {
return
}
activeTab.value = tab
activeFolder.value = ''
searchText.value = ''
clearMediaSelection()
closeMediaDetail()
}
/**
* ISO 시각을 짧은 로캘 문자열로 표시한다.
* @param {string | null} iso - ISO 시각
* @returns {string} 표시 문자열
*/
const formatDateTime = (iso) => {
if (!iso) {
return '—'
}
try {
return new Date(iso).toLocaleString('ko-KR', {
dateStyle: 'medium',
timeStyle: 'short'
})
} catch {
return '—'
}
}
const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', { const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', {
default: () => ['미분류'] default: () => ['미분류']
}) })
@@ -39,7 +107,7 @@ const normalizedFolders = computed(() => {
}, '') }, '')
}) })
mediaItems.value.forEach((item) => { libraryMediaItems.value.forEach((item) => {
String(item.category || '미분류').split('/').filter(Boolean).reduce((parentPath, segment) => { String(item.category || '미분류').split('/').filter(Boolean).reduce((parentPath, segment) => {
const nextPath = parentPath ? `${parentPath}/${segment}` : segment const nextPath = parentPath ? `${parentPath}/${segment}` : segment
folderSet.add(nextPath) folderSet.add(nextPath)
@@ -51,21 +119,29 @@ const normalizedFolders = computed(() => {
}) })
const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts, folder) => { const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts, folder) => {
counts[folder] = mediaItems.value.filter((item) => item.category === folder || item.category?.startsWith(`${folder}/`)).length counts[folder] = libraryMediaItems.value.filter((item) => item.category === folder || item.category?.startsWith(`${folder}/`)).length
return counts return counts
}, {})) }, {}))
const filteredMediaItems = computed(() => { const filteredMediaItems = computed(() => {
const query = searchText.value.trim().toLowerCase() const query = searchText.value.trim().toLowerCase()
const folder = activeFolder.value const folder = activeFolder.value
const base = scopeItems.value
return mediaItems.value.filter((item) => { return base.filter((item) => {
const matchesFolder = !folder || item.category === folder || item.category?.startsWith(`${folder}/`) const matchesFolder = activeTab.value === 'thumbnails'
? true
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
const usageTitles = item.usage?.map((usage) => usage.title) || []
const ownerFields = item.avatarOwner
? [item.avatarOwner.username, item.avatarOwner.email, item.avatarOwner.lastSeenIp]
: []
const matchesQuery = !query || [ const matchesQuery = !query || [
item.name, item.name,
item.url, item.url,
item.category, item.category,
...item.usage.map((usage) => usage.title) ...usageTitles,
...ownerFields
].some((value) => String(value || '').toLowerCase().includes(query)) ].some((value) => String(value || '').toLowerCase().includes(query))
return matchesFolder && matchesQuery return matchesFolder && matchesQuery
@@ -209,6 +285,10 @@ const closeCreateFolderModal = () => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const submitCreateFolderModal = async () => { const submitCreateFolderModal = async () => {
if (activeTab.value !== 'library') {
return
}
const folderName = createFolderModalName.value.trim() const folderName = createFolderModalName.value.trim()
if (!folderName) { if (!folderName) {
@@ -240,7 +320,7 @@ const submitCreateFolderModal = async () => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const removeMediaFolder = async (folder) => { const removeMediaFolder = async (folder) => {
if (!folder || folder === '미분류') { if (!folder || folder === '미분류' || folder === MEDIA_THUMBNAIL_ROOT || folder.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
return return
} }
@@ -281,6 +361,10 @@ const removeMediaFolder = async (folder) => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => { const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
if (activeTab.value !== 'library') {
return
}
const targetUrls = [...new Set(urls.filter(Boolean))] const targetUrls = [...new Set(urls.filter(Boolean))]
if (!targetUrls.length) { if (!targetUrls.length) {
@@ -315,6 +399,10 @@ const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
* @returns {void} * @returns {void}
*/ */
const startMediaDrag = (event, item) => { const startMediaDrag = (event, item) => {
if (activeTab.value !== 'library') {
return
}
if (!isMediaSelected(item)) { if (!isMediaSelected(item)) {
selectedMediaUrls.value = [item.url] selectedMediaUrls.value = [item.url]
} }
@@ -333,6 +421,11 @@ const startMediaDrag = (event, item) => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const dropMediaOnFolder = async (folder) => { const dropMediaOnFolder = async (folder) => {
if (activeTab.value !== 'library') {
draggingUrls.value = []
return
}
const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value
draggingUrls.value = [] draggingUrls.value = []
await moveMediaToFolder(folder, urls) await moveMediaToFolder(folder, urls)
@@ -343,6 +436,10 @@ const dropMediaOnFolder = async (folder) => {
* @returns {Promise<void>} 저장 결과 * @returns {Promise<void>} 저장 결과
*/ */
const saveMediaCategory = async () => { const saveMediaCategory = async () => {
if (selectedMedia.value?.avatarOwner) {
return
}
errorMessage.value = '' errorMessage.value = ''
try { try {
@@ -369,8 +466,8 @@ const saveMediaCategory = async () => {
const renameMedia = async () => { const renameMedia = async () => {
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value) const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
if (editingItem?.usage.length) { if (editingItem && isMediaItemLocked(editingItem)) {
errorMessage.value = '사용 중인 미디어는 파일명을 변경할 수 없습니다.' errorMessage.value = '사용 중이거나 회원 썸네일인 미디어는 파일명을 변경할 수 없습니다.'
return return
} }
@@ -398,8 +495,8 @@ const renameMedia = async () => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const deleteMedia = async (item) => { const deleteMedia = async (item) => {
if (item.usage.length) { if (isMediaItemLocked(item)) {
errorMessage.value = '사용 중인 미디어는 삭제할 수 없습니다.' errorMessage.value = '사용 중이거나 회원 썸네일인 미디어는 삭제할 수 없습니다.'
return return
} }
@@ -429,21 +526,41 @@ const deleteMedia = async (item) => {
<template> <template>
<section class="admin-media bg-paper p-6"> <section class="admin-media bg-paper p-6">
<div class="admin-media__header flex flex-wrap items-center justify-between gap-4"> <div class="admin-media__header flex flex-col gap-4">
<div> <div class="flex flex-wrap items-center justify-between gap-4">
<p class="admin-media__eyebrow text-xs font-semibold uppercase text-muted"> <div>
Media <p class="admin-media__eyebrow text-xs font-semibold uppercase text-muted">
</p> Media
<h1 class="admin-media__title mt-2 text-3xl font-semibold"> </p>
미디어 <h1 class="admin-media__title mt-2 text-3xl font-semibold">
</h1> 미디어
</h1>
<div class="admin-media__tabs mt-3 inline-flex rounded-lg border border-line bg-surface p-0.5">
<button
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
:class="activeTab === 'library' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
type="button"
@click="setActiveTab('library')"
>
미디어 라이브러리
</button>
<button
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
:class="activeTab === 'thumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
type="button"
@click="setActiveTab('thumbnails')"
>
썸네일
</button>
</div>
</div>
<input
v-model="searchText"
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
type="search"
:placeholder="activeTab === 'thumbnails' ? '닉네임, 이메일, IP, 파일명 검색' : '파일명, 경로, 폴더, 사용처 검색'"
>
</div> </div>
<input
v-model="searchText"
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
type="search"
placeholder="파일명, 경로, 폴더, 사용처 검색"
>
</div> </div>
<p v-if="errorMessage" class="admin-media__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <p v-if="errorMessage" class="admin-media__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
@@ -451,7 +568,10 @@ const deleteMedia = async (item) => {
</p> </p>
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]"> <div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside class="admin-media__folders rounded border border-line bg-white p-3"> <aside
v-if="activeTab === 'library'"
class="admin-media__folders rounded border border-line bg-white p-3"
>
<button <button
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold hover:bg-surface" class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold hover:bg-surface"
:class="!activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'" :class="!activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
@@ -461,7 +581,7 @@ const deleteMedia = async (item) => {
@drop.prevent="dropMediaOnFolder('미분류')" @drop.prevent="dropMediaOnFolder('미분류')"
> >
<span>전체 미디어</span> <span>전체 미디어</span>
<span>{{ mediaItems.length }}</span> <span>{{ libraryMediaItems.length }}</span>
</button> </button>
<div class="admin-media__folder-list mt-3 grid gap-1"> <div class="admin-media__folder-list mt-3 grid gap-1">
@@ -483,7 +603,7 @@ const deleteMedia = async (item) => {
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span> <span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span>
</button> </button>
<button <button
v-if="folder !== '미분류'" v-if="folder !== '미분류' && !isThumbnailFolderPath(folder)"
class="admin-media__folder-delete mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded text-current opacity-40 transition hover:opacity-100 hover:text-red-300 disabled:opacity-25" class="admin-media__folder-delete mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded text-current opacity-40 transition hover:opacity-100 hover:text-red-300 disabled:opacity-25"
type="button" type="button"
:disabled="deletingFolder === folder" :disabled="deletingFolder === folder"
@@ -497,7 +617,7 @@ const deleteMedia = async (item) => {
</div> </div>
</div> </div>
<div class="admin-media__folder-actions mt-4 border-t border-line pt-4"> <div v-if="activeTab === 'library'" class="admin-media__folder-actions mt-4 border-t border-line pt-4">
<button <button
class="admin-media__folder-add w-full rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white" class="admin-media__folder-add w-full rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white"
type="button" type="button"
@@ -508,11 +628,28 @@ const deleteMedia = async (item) => {
</div> </div>
</aside> </aside>
<aside
v-else
class="admin-media__folders admin-media__folders--thumbnails rounded border border-line bg-white p-3"
>
<button
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold bg-[#15171a] text-white hover:bg-[#15171a]"
type="button"
@click="selectFolder('')"
>
<span>전체 썸네일</span>
<span>{{ thumbnailMediaItems.length }}</span>
</button>
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
회원 프로필에서 저장된 이미지만 표시됩니다. 파일 삭제·이름 변경은 화면에서 없으며, 회원이 프로필에서 바꾸면 갱신됩니다.
</p>
</aside>
<div class="admin-media__content min-w-0"> <div class="admin-media__content min-w-0">
<div class="admin-media__toolbar flex flex-wrap items-center justify-between gap-3"> <div class="admin-media__toolbar flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 class="admin-media__folder-title text-lg font-semibold text-ink"> <h2 class="admin-media__folder-title text-lg font-semibold text-ink">
{{ activeFolder || '전체 미디어' }} {{ activeTab === 'thumbnails' ? '썸네일' : (activeFolder || '전체 미디어') }}
</h2> </h2>
<p class="admin-media__folder-summary mt-1 text-xs text-muted"> <p class="admin-media__folder-summary mt-1 text-xs text-muted">
{{ filteredMediaItems.length }} 표시 {{ filteredMediaItems.length }} 표시
@@ -531,7 +668,7 @@ const deleteMedia = async (item) => {
v-for="(item, index) in filteredMediaItems" v-for="(item, index) in filteredMediaItems"
:key="item.url" :key="item.url"
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]" class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]"
draggable="true" :draggable="activeTab === 'library'"
@dragstart="startMediaDrag($event, item)" @dragstart="startMediaDrag($event, item)"
> >
<button <button
@@ -566,7 +703,13 @@ const deleteMedia = async (item) => {
> >
<img class="admin-media__image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title"> <img class="admin-media__image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
<span <span
v-if="item.usage.length" v-if="item.avatarOwner"
class="admin-media__usage-badge pointer-events-none absolute right-1.5 top-1.5 rounded bg-emerald-800 px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
회원
</span>
<span
v-else-if="item.usage.length"
class="admin-media__usage-badge pointer-events-none absolute right-1.5 top-1.5 rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white" class="admin-media__usage-badge pointer-events-none absolute right-1.5 top-1.5 rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
> >
{{ item.usage.length }} {{ item.usage.length }}
@@ -675,22 +818,56 @@ const deleteMedia = async (item) => {
<input <input
id="media-category" id="media-category"
v-model="editingCategory" v-model="editingCategory"
class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm" class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-surface disabled:opacity-70"
type="text" type="text"
list="media-folder-options" list="media-folder-options"
placeholder="미분류" placeholder="미분류"
:disabled="Boolean(selectedMedia.avatarOwner)"
@keydown.enter.prevent="saveMediaCategory" @keydown.enter.prevent="saveMediaCategory"
> >
<button class="admin-media__category-save rounded border border-line px-3 py-2 text-xs font-semibold" type="button" @click="saveMediaCategory"> <button
class="admin-media__category-save rounded border border-line px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="Boolean(selectedMedia.avatarOwner)"
@click="saveMediaCategory"
>
저장 저장
</button> </button>
</div> </div>
<p v-if="selectedMedia.avatarOwner" class="admin-media__category-hint text-xs text-muted">
프로필 썸네일의 논리 폴더는 {{ MEDIA_THUMBNAIL_ROOT }} 고정됩니다.
</p>
<datalist id="media-folder-options"> <datalist id="media-folder-options">
<option v-for="folder in normalizedFolders" :key="folder" :value="folder" /> <option v-for="folder in normalizedFolders" :key="folder" :value="folder" />
</datalist> </datalist>
</div> </div>
<div class="admin-media__usage rounded bg-surface p-3 text-xs"> <div
v-if="selectedMedia.avatarOwner"
class="admin-media__avatar-owner rounded border border-line bg-surface p-3 text-xs"
>
<strong class="admin-media__avatar-owner-title text-ink">연결된 회원</strong>
<dl class="admin-media__avatar-owner-fields mt-2 grid gap-2 text-muted">
<div class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">닉네임</dt>
<dd class="mt-0.5 font-semibold text-ink">{{ selectedMedia.avatarOwner.username }}</dd>
</div>
<div class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">이메일</dt>
<dd class="mt-0.5 break-all text-ink">{{ selectedMedia.avatarOwner.email }}</dd>
</div>
<div class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 접속</dt>
<dd class="mt-0.5 text-ink">{{ formatDateTime(selectedMedia.avatarOwner.lastSeenAt) }}</dd>
</div>
<div v-if="selectedMedia.avatarOwner.lastSeenIp" class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 IP</dt>
<dd class="mt-0.5 break-all font-mono text-[11px] text-ink">{{ selectedMedia.avatarOwner.lastSeenIp }}</dd>
</div>
</dl>
</div>
<div v-if="!selectedMedia.avatarOwner" class="admin-media__usage rounded bg-surface p-3 text-xs">
<strong class="admin-media__usage-title text-ink"> <strong class="admin-media__usage-title text-ink">
사용 현황 {{ selectedMedia.usage.length }} 사용 현황 {{ selectedMedia.usage.length }}
</strong> </strong>
@@ -721,12 +898,12 @@ const deleteMedia = async (item) => {
v-model="editingName" v-model="editingName"
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50" class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50"
type="text" type="text"
:disabled="selectedMedia.usage.length > 0" :disabled="isMediaItemLocked(selectedMedia)"
:placeholder="selectedMedia.title" :placeholder="selectedMedia.title"
@keydown.enter.prevent="renameMedia" @keydown.enter.prevent="renameMedia"
> >
<p v-if="selectedMedia.usage.length" class="admin-media__locked text-xs text-muted"> <p v-if="isMediaItemLocked(selectedMedia)" class="admin-media__locked text-xs text-muted">
사용 중인 미디어는 파일명 변경과 삭제가 잠겨 있습니다. 사용 이거나 회원 썸네일 미디어는 파일명 변경과 삭제가 잠겨 있습니다.
</p> </p>
</div> </div>
@@ -734,7 +911,7 @@ const deleteMedia = async (item) => {
<button <button
class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50" class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50"
type="button" type="button"
:disabled="selectedMedia.usage.length > 0 || !editingName" :disabled="isMediaItemLocked(selectedMedia) || !editingName"
@click="renameMedia" @click="renameMedia"
> >
파일명 저장 파일명 저장
@@ -742,7 +919,7 @@ const deleteMedia = async (item) => {
<button <button
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50" class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
type="button" type="button"
:disabled="deletingUrl === selectedMedia.url || selectedMedia.usage.length > 0" :disabled="deletingUrl === selectedMedia.url || isMediaItemLocked(selectedMedia)"
@click="deleteMedia(selectedMedia)" @click="deleteMedia(selectedMedia)"
> >
{{ deletingUrl === selectedMedia.url ? '삭제 중' : '삭제' }} {{ deletingUrl === selectedMedia.url ? '삭제 중' : '삭제' }}

View File

@@ -3,10 +3,10 @@ import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
import { createError, readMultipartFormData } from 'h3' import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp' import sharp from 'sharp'
import { getPostgresClient } from '../../repositories/postgres-client'
import { updateMemberProfile, getUserById } from '../../repositories/member-repository' import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth' import { requireMemberSession } from '../../utils/member-auth'
import { removeManagedAvatarAsset } from '../../utils/member-avatar' import { removeManagedAvatarAsset } from '../../utils/member-avatar'
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/media-library'
const allowedImageTypes = new Map([ const allowedImageTypes = new Map([
['image/jpeg', '.jpg'], ['image/jpeg', '.jpg'],
@@ -145,17 +145,7 @@ export default defineEventHandler(async (event) => {
avatarUrl avatarUrl
}) })
const sql = getPostgresClient() await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
if (sql) {
await sql`
INSERT INTO media_metadata (url, category)
VALUES (${avatarUrl}, ${'회원/썸네일'})
ON CONFLICT (url) DO UPDATE
SET
category = EXCLUDED.category,
updated_at = now()
`
}
if (currentUser.avatarUrl && currentUser.avatarUrl !== avatarUrl) { if (currentUser.avatarUrl && currentUser.avatarUrl !== avatarUrl) {
await removeManagedAvatarAsset(currentUser.avatarUrl) await removeManagedAvatarAsset(currentUser.avatarUrl)

View File

@@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
import { extname, join } from 'node:path' import { extname, join } from 'node:path'
import { createError, readMultipartFormData } from 'h3' import { createError, readMultipartFormData } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth' import { requireAdminSession } from '../../../utils/admin-auth'
import { upsertMediaMetadataCategory } from '../../../utils/media-library'
const allowedImageTypes = new Map([ const allowedImageTypes = new Map([
['image/jpeg', '.jpg'], ['image/jpeg', '.jpg'],
@@ -89,8 +90,12 @@ export default defineEventHandler(async (event) => {
await writeFile(filePath, file.data) await writeFile(filePath, file.data)
const publicUrl = `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`
await upsertMediaMetadataCategory(publicUrl, '미분류')
uploadedFiles.push({ uploadedFiles.push({
url: `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`, url: publicUrl,
name: file.filename, name: file.filename,
size: file.data.length size: file.data.length
}) })

View File

@@ -6,12 +6,182 @@ import { getPostgresClient } from '../repositories/postgres-client'
const uploadRoot = join(process.cwd(), 'public', 'uploads') const uploadRoot = join(process.cwd(), 'public', 'uploads')
/** 회원 프로필 이미지 전용 논리 폴더명(디스크 경로와 별도) */
export const MEDIA_THUMBNAIL_ROOT = '썸네일'
/**
* 회원 아바타 공개 URL 여부
* @param {string} url - 미디어 URL
* @returns {boolean} 아바타 경로이면 true
*/
export const isMemberAvatarPublicUrl = (url) => typeof url === 'string' && url.includes('/members/avatars/')
/**
* 미디어 카테고리 정리
* @param {string} category - 입력 카테고리
* @returns {string} 정리된 카테고리
*/
const normalizeMediaCategory = (category) => String(category || '')
.trim()
.replace(/\s+/g, ' ')
.replace(/\/+/g, '/')
.replace(/^\/|\/$/g, '')
|| '미분류'
/** /**
* 기본 미디어 카테고리 이름 반환 * 기본 미디어 카테고리 이름 반환
* @param {string} relativePath - 업로드 루트 기준 상대 경로 * @param {string} relativePath - 업로드 루트 기준 상대 경로
* @returns {string} 기본 카테고리 * @returns {string} 기본 카테고리
*/ */
const getDefaultMediaCategory = (relativePath) => relativePath.split('/')[0] || '미분류' const getDefaultMediaCategory = (relativePath) => {
if (relativePath.startsWith('posts/')) {
return '미분류'
}
return relativePath.split('/')[0] || '미분류'
}
/**
* 저장된 논리 폴더명을 화면·API 기준으로 정규화한다.
* @param {string} url - 미디어 URL
* @param {string} category - DB 또는 디스크 기본 카테고리
* @returns {string} 정규화된 카테고리
*/
const normalizeStoredDisplayCategory = (url, category) => {
if (isMemberAvatarPublicUrl(url)) {
return MEDIA_THUMBNAIL_ROOT
}
const base = normalizeMediaCategory(category)
if (base === 'posts' || base === '회원/썸네일') {
return '미분류'
}
return base
}
/**
* 논리 폴더 경로 목록에서 썸네일 전용 루트를 제외한다.
* @param {Array<string>} paths - 폴더 경로 목록
* @returns {Array<string>} 필터된 목록
*/
const excludeThumbnailFolderPaths = (paths) => paths.filter((pathValue) => pathValue !== MEDIA_THUMBNAIL_ROOT
&& !pathValue.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`))
/**
* 썸네일 전용 폴더 경로에 대한 변경을 검증한다.
* @param {Array<string>} urls - 대상 URL 목록
* @param {string} normalizedCategory - 목표 논리 폴더
* @returns {void}
*/
const assertCategoryMoveAllowed = (urls, normalizedCategory) => {
const uniqueUrls = [...new Set(urls.filter(Boolean))]
const hasAvatar = uniqueUrls.some((u) => isMemberAvatarPublicUrl(u))
const hasNonAvatar = uniqueUrls.some((u) => !isMemberAvatarPublicUrl(u))
if (hasNonAvatar && normalizedCategory === MEDIA_THUMBNAIL_ROOT) {
throw createError({
statusCode: 400,
message: '일반 미디어를 썸네일 폴더로 옮길 수 없습니다.'
})
}
if (hasAvatar && normalizedCategory !== MEDIA_THUMBNAIL_ROOT) {
throw createError({
statusCode: 400,
message: '프로필 썸네일의 논리 폴더는 「썸네일」로만 유지됩니다.'
})
}
}
/**
* 회원 아바타 파일은 라이브러리에서 직접 삭제·이름 변경하지 않는다.
* @param {string} url - 미디어 URL
* @returns {void}
*/
const assertNotMemberAvatarFile = (url) => {
if (isMemberAvatarPublicUrl(url)) {
throw createError({
statusCode: 400,
message: '회원 프로필 썸네일은 이 화면에서 삭제·이름 변경할 수 없습니다.'
})
}
}
/**
* URL 목록에 대해 아바타를 사용 중인 회원 정보를 조회한다.
* @param {Array<string>} urls - 미디어 URL 목록
* @returns {Promise<Map<string, { id: string, username: string, email: string, lastSeenAt: string | null, lastSeenIp: string }>>} URL별 회원 요약
*/
const getAvatarOwnersByUrls = async (urls) => {
const sql = getPostgresClient()
const uniqueUrls = [...new Set(urls.filter((u) => isMemberAvatarPublicUrl(u)))]
if (!sql || !uniqueUrls.length) {
return new Map()
}
const rows = await sql`
SELECT
id,
username,
email,
last_seen_at,
last_seen_ip,
avatar_url
FROM users
WHERE avatar_url IN ${sql(uniqueUrls)}
`
const ownerByUrl = new Map()
for (const row of rows) {
if (!row.avatar_url || ownerByUrl.has(row.avatar_url)) {
continue
}
ownerByUrl.set(row.avatar_url, {
id: row.id,
username: row.username,
email: row.email,
lastSeenAt: row.last_seen_at ? row.last_seen_at.toISOString() : null,
lastSeenIp: row.last_seen_ip || ''
})
}
return ownerByUrl
}
/**
* 미디어 논리 폴더 메타를 upsert한다.
* @param {string} url - 미디어 URL
* @param {string} category - 논리 폴더명
* @returns {Promise<void>}
*/
export const upsertMediaMetadataCategory = async (url, category) => {
const sql = getPostgresClient()
if (!sql) {
return
}
const normalizedCategory = normalizeMediaCategory(category)
await sql`
INSERT INTO media_metadata (
url,
category
)
VALUES (
${url},
${normalizedCategory}
)
ON CONFLICT (url) DO UPDATE
SET
category = EXCLUDED.category,
updated_at = now()
`
}
/** /**
* 미디어 메타데이터 목록을 URL 기준 객체로 조회 * 미디어 메타데이터 목록을 URL 기준 객체로 조회
@@ -35,18 +205,6 @@ const getMediaMetadataMap = async () => {
}])) }]))
} }
/**
* 미디어 카테고리 정리
* @param {string} category - 입력 카테고리
* @returns {string} 정리된 카테고리
*/
const normalizeMediaCategory = (category) => String(category || '')
.trim()
.replace(/\s+/g, ' ')
.replace(/\/+/g, '/')
.replace(/^\/|\/$/g, '')
|| '미분류'
/** /**
* 미디어 폴더 목록 조회 * 미디어 폴더 목록 조회
* @returns {Promise<Array<string>>} 미디어 폴더 경로 목록 * @returns {Promise<Array<string>>} 미디어 폴더 경로 목록
@@ -55,10 +213,15 @@ export const listMediaFolders = async () => {
const sql = getPostgresClient() const sql = getPostgresClient()
const items = await readMediaDirectory(uploadRoot) const items = await readMediaDirectory(uploadRoot)
const metadataMap = await getMediaMetadataMap() const metadataMap = await getMediaMetadataMap()
const defaultCategories = items.map((item) => metadataMap[item.url]?.category || item.category) const defaultCategories = items.map((item) => {
const rawCategory = metadataMap[item.url]?.category || item.category
return normalizeStoredDisplayCategory(item.url, rawCategory)
})
if (!sql) { if (!sql) {
return [...new Set(['미분류', ...defaultCategories])].sort((left, right) => left.localeCompare(right)) return excludeThumbnailFolderPaths([...new Set(['미분류', ...defaultCategories])])
.sort((left, right) => left.localeCompare(right))
} }
const rows = await sql` const rows = await sql`
@@ -67,11 +230,11 @@ export const listMediaFolders = async () => {
ORDER BY path ASC ORDER BY path ASC
` `
return [...new Set([ return excludeThumbnailFolderPaths([...new Set([
'미분류', '미분류',
...rows.map((row) => row.path), ...rows.map((row) => row.path),
...defaultCategories ...defaultCategories
])].sort((left, right) => left.localeCompare(right)) ])]).sort((left, right) => left.localeCompare(right))
} }
/** /**
@@ -87,6 +250,13 @@ export const createMediaFolder = async (path) => {
throw new Error('DATABASE_REQUIRED') throw new Error('DATABASE_REQUIRED')
} }
if (normalizedPath === MEDIA_THUMBNAIL_ROOT || normalizedPath.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
throw createError({
statusCode: 400,
message: '「썸네일」 폴더는 시스템에서만 관리합니다.'
})
}
await sql` await sql`
INSERT INTO media_folders (path) INSERT INTO media_folders (path)
VALUES (${normalizedPath}) VALUES (${normalizedPath})
@@ -119,6 +289,13 @@ export const deleteMediaFolder = async (path) => {
}) })
} }
if (normalizedPath === MEDIA_THUMBNAIL_ROOT || normalizedPath.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
throw createError({
statusCode: 400,
message: '「썸네일」 폴더는 삭제할 수 없습니다.'
})
}
const childPrefix = `${normalizedPath}/` const childPrefix = `${normalizedPath}/`
await sql.begin(async (tx) => { await sql.begin(async (tx) => {
@@ -301,11 +478,19 @@ export const listMediaItems = async () => {
listAdminPosts(), listAdminPosts(),
listPages() listPages()
]) ])
const itemsWithUsage = items.map((item) => ({ const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
...item, const itemsWithUsage = items.map((item) => {
category: metadataMap[item.url]?.category || item.category, const rawCategory = metadataMap[item.url]?.category ?? item.category
usage: getMediaUsage(item.url, posts, pages) const category = normalizeStoredDisplayCategory(item.url, rawCategory)
})) const avatarOwner = avatarOwnerByUrl.get(item.url) || null
return {
...item,
category,
usage: getMediaUsage(item.url, posts, pages),
avatarOwner
}
})
return itemsWithUsage.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt)) return itemsWithUsage.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt))
} }
@@ -376,7 +561,11 @@ export const updateMediaCategories = async (urls, category) => {
throw new Error('DATABASE_REQUIRED') throw new Error('DATABASE_REQUIRED')
} }
await createMediaFolder(normalizedCategory) assertCategoryMoveAllowed(urls, normalizedCategory)
if (normalizedCategory !== MEDIA_THUMBNAIL_ROOT) {
await createMediaFolder(normalizedCategory)
}
const items = [] const items = []
@@ -401,7 +590,7 @@ export const updateMediaCategories = async (urls, category) => {
const item = await createMediaItem(mediaPath) const item = await createMediaItem(mediaPath)
items.push({ items.push({
...item, ...item,
category: normalizedCategory, category: normalizeStoredDisplayCategory(url, normalizedCategory),
usage: [] usage: []
}) })
} }
@@ -415,6 +604,8 @@ export const updateMediaCategories = async (urls, category) => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export const deleteMediaItem = async (url) => { export const deleteMediaItem = async (url) => {
assertNotMemberAvatarFile(url)
const [posts, pages] = await Promise.all([ const [posts, pages] = await Promise.all([
listAdminPosts(), listAdminPosts(),
listPages() listPages()
@@ -439,6 +630,8 @@ export const deleteMediaItem = async (url) => {
* @returns {Promise<Object>} 변경된 미디어 항목 * @returns {Promise<Object>} 변경된 미디어 항목
*/ */
export const renameMediaItem = async (url, name) => { export const renameMediaItem = async (url, name) => {
assertNotMemberAvatarFile(url)
const [posts, pages] = await Promise.all([ const [posts, pages] = await Promise.all([
listAdminPosts(), listAdminPosts(),
listPages() listPages()