diff --git a/db/migrations/016_media_category_normalize.sql b/db/migrations/016_media_category_normalize.sql new file mode 100644 index 0000000..8d5bd30 --- /dev/null +++ b/db/migrations/016_media_category_normalize.sql @@ -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 = '회원/썸네일'; diff --git a/docs/history.md b/docs/history.md index 80aad57..f337abe 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-12 v0.0.90 + +### 관리자 미디어 라이브러리와 썸네일 탭 분리 + +게시물 이미지는 디스크상 `posts/`에 두더라도 논리 분류는 `미분류`로 통일해 `posts` 트리와 이중 표기를 없앤다. 회원 프로필 이미지는 디스크 경로는 유지하되 논리 폴더를 예약명 `썸네일`로 고정하고, 관리자 화면에서는 탭을 나눠 검색·탐색 대상을 분리했다. 썸네일 파일은 URL이 회원 콘텐츠와 직결되므로 관리자에서 임의 삭제·이름 변경이 되면 프로필이 깨지기 쉬워 API·UI에서 막는다. + ## 2026-05-12 v0.0.89 ### 미디어 선택 토글 가시성 diff --git a/docs/map.md b/docs/map.md index 5251409..8d44ec7 100644 --- a/docs/map.md +++ b/docs/map.md @@ -81,7 +81,7 @@ | pages/admin/pages/index.vue | 페이지 목록 | | pages/admin/pages/new.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/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 | | pages/admin/tags/new.vue | 태그 생성 | @@ -124,7 +124,7 @@ | server/api/auth/logout.post.js | 회원 로그아웃 API | | server/api/auth/profile.get.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/check-username.get.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-folders.get.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.post.js | 관리자 태그 생성 API | | server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API | @@ -175,7 +175,7 @@ | server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 | | server/utils/site-settings.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/content-repository.js | 콘텐츠 조회 저장소 | | server/repositories/member-repository.js | 회원 조회/생성 저장소 | @@ -192,6 +192,7 @@ | db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 | | db/migrations/006_add_media_metadata.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/009_add_post_og_image.sql | 게시물 OG 이미지 필드 추가 | | db/migrations/010_add_members_and_comments.sql | 회원/댓글 테이블 추가 | diff --git a/docs/spec.md b/docs/spec.md index 8bd19bc..eb2e6e9 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -300,7 +300,7 @@ components/content/ | 필드 | 타입 | 설명 | |------|------|------| | url | String | 업로드 미디어 URL | -| category | String | 관리자 미디어 폴더 경로 | +| category | String | 논리 폴더 경로(게시물 업로드 이미지는 `미분류`, 회원 아바타는 예약값 `썸네일` 등) | | created_at | DateTime | 생성일 | | updated_at | DateTime | 수정일 | @@ -366,7 +366,7 @@ components/content/ > 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다. > `AVATAR_WEBP_QUALITY`는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다. > 회원 썸네일을 새로 업로드하거나 제거/탈퇴할 때 기존 회원 썸네일 파일과 메타데이터는 자동 정리한다. -> 관리자 미디어 화면에서는 회원 썸네일도 `회원/썸네일` 폴더로 조회 가능하다. +> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다. ### 관리자 API (`/admin/api/`) @@ -383,9 +383,9 @@ components/content/ - `GET /admin/api/pages/:id` - 고정 페이지 상세 - `PUT /admin/api/pages/:id` - 고정 페이지 수정 - `DELETE /admin/api/pages/:id` - 고정 페이지 삭제 -- `GET /admin/api/media` - 업로드 미디어 목록 -- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경 -- `DELETE /admin/api/media` - 업로드 미디어 삭제 +- `GET /admin/api/media` - 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 `avatarOwner` 요약이 붙을 수 있음) +- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더 `썸네일`만 허용, 일반 미디어를 `썸네일`로 옮기는 것은 거부) +- `DELETE /admin/api/media` - 업로드 미디어 삭제(회원 아바타 디스크 경로 파일은 거부) - `GET /admin/api/media-folders` - 미디어 폴더 목록 - `POST /admin/api/media-folders` - 미디어 폴더 생성 - `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림) @@ -559,23 +559,25 @@ components/content/ /uploads/system/favicon.png ``` -- 디스크 상대 경로의 **첫 번째 디렉터리 이름**(`posts`, `pages`, `members` 등)이 `media_metadata`가 없을 때 미디어 라이브러리에 보이는 **기본 폴더(논리 분류)**가 된다. 관리자 에디터 업로드 API는 현재 `public/uploads/posts/YYYY/MM/`에만 저장하므로, 메타가 없는 새 이미지는 기본적으로 **`posts`** 트리 아래로 잡힌다. -- `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category`가 `미분류`로 저장된 항목이 여기에 모인다. 디스크 경로가 `posts/...`인데도 화면에 `미분류`로 보이려면 메타가 `미분류`로 저장돼 있어야 한다. -- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 업로드 시 서버가 `media_metadata`에 **`회원/썸네일`** 카테고리를 넣어 미디어 라이브러리에서 회원 영역으로 묶인다(자동으로 `미분류`에 들어가지 않는다). +- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). +- `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category`가 `미분류`로 저장된 항목이 여기에 모인다. +- 회원 썸네일은 `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`만 허용한다. - 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다. - 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다. -- 관리자 미디어 화면은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다. -- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`를 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다. +- 관리자 미디어 화면 상단에 **미디어 라이브러리** 탭과 **썸네일** 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 썸네일 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. +- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다. +- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다. - 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다. -- 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다. -- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 상세 모달에서 표시한다. +- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다. +- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다. - 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다. - 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다. - 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다. - 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다. -- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다. +- 회원 프로필 썸네일 파일은 관리자 화면에서 파일명 변경·삭제를 차단한다. --- diff --git a/docs/update.md b/docs/update.md index fd9a935..cf7d050 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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 - 관리자 미디어 썸네일 선택 컨트롤을 네이티브 체크박스에서 대비가 분명한 토글 버튼(미선택: 흰 배경·진한 테두리, 선택: 진한 배경·흰 체크)으로 교체. -- `docs/spec.md`에 `posts`·`미분류`·`회원/썸네일`과 디스크 경로·`media_metadata` 관계를 명시. +- `docs/spec.md`에 미디어 디스크 경로와 `media_metadata` 논리 폴더 관계를 명시(이후 v0.0.90에서 `미분류`/`썸네일` 정책으로 갱신). ## v0.0.88 diff --git a/package.json b/package.json index d536872..440b2a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.89", + "version": "0.0.90", "private": true, "type": "module", "imports": { diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index 8a741f0..6b9b2d1 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -3,6 +3,17 @@ definePageMeta({ 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 activeFolder = ref('') const isCreateFolderModalOpen = ref(false) @@ -22,6 +33,63 @@ const { data: mediaItems, refresh } = await useFetch('/admin/api/media', { 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', { 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) => { const nextPath = parentPath ? `${parentPath}/${segment}` : segment folderSet.add(nextPath) @@ -51,21 +119,29 @@ const normalizedFolders = computed(() => { }) 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 }, {})) const filteredMediaItems = computed(() => { const query = searchText.value.trim().toLowerCase() const folder = activeFolder.value + const base = scopeItems.value - return mediaItems.value.filter((item) => { - const matchesFolder = !folder || item.category === folder || item.category?.startsWith(`${folder}/`) + return base.filter((item) => { + 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 || [ item.name, item.url, item.category, - ...item.usage.map((usage) => usage.title) + ...usageTitles, + ...ownerFields ].some((value) => String(value || '').toLowerCase().includes(query)) return matchesFolder && matchesQuery @@ -209,6 +285,10 @@ const closeCreateFolderModal = () => { * @returns {Promise} */ const submitCreateFolderModal = async () => { + if (activeTab.value !== 'library') { + return + } + const folderName = createFolderModalName.value.trim() if (!folderName) { @@ -240,7 +320,7 @@ const submitCreateFolderModal = async () => { * @returns {Promise} */ const removeMediaFolder = async (folder) => { - if (!folder || folder === '미분류') { + if (!folder || folder === '미분류' || folder === MEDIA_THUMBNAIL_ROOT || folder.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) { return } @@ -281,6 +361,10 @@ const removeMediaFolder = async (folder) => { * @returns {Promise} */ const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => { + if (activeTab.value !== 'library') { + return + } + const targetUrls = [...new Set(urls.filter(Boolean))] if (!targetUrls.length) { @@ -315,6 +399,10 @@ const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => { * @returns {void} */ const startMediaDrag = (event, item) => { + if (activeTab.value !== 'library') { + return + } + if (!isMediaSelected(item)) { selectedMediaUrls.value = [item.url] } @@ -333,6 +421,11 @@ const startMediaDrag = (event, item) => { * @returns {Promise} */ const dropMediaOnFolder = async (folder) => { + if (activeTab.value !== 'library') { + draggingUrls.value = [] + return + } + const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value draggingUrls.value = [] await moveMediaToFolder(folder, urls) @@ -343,6 +436,10 @@ const dropMediaOnFolder = async (folder) => { * @returns {Promise} 저장 결과 */ const saveMediaCategory = async () => { + if (selectedMedia.value?.avatarOwner) { + return + } + errorMessage.value = '' try { @@ -369,8 +466,8 @@ const saveMediaCategory = async () => { const renameMedia = async () => { const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value) - if (editingItem?.usage.length) { - errorMessage.value = '사용 중인 미디어는 파일명을 변경할 수 없습니다.' + if (editingItem && isMediaItemLocked(editingItem)) { + errorMessage.value = '사용 중이거나 회원 썸네일인 미디어는 파일명을 변경할 수 없습니다.' return } @@ -398,8 +495,8 @@ const renameMedia = async () => { * @returns {Promise} */ const deleteMedia = async (item) => { - if (item.usage.length) { - errorMessage.value = '사용 중인 미디어는 삭제할 수 없습니다.' + if (isMediaItemLocked(item)) { + errorMessage.value = '사용 중이거나 회원 썸네일인 미디어는 삭제할 수 없습니다.' return } @@ -429,21 +526,41 @@ const deleteMedia = async (item) => {