diff --git a/docs/history.md b/docs/history.md index 89243d5..4784f71 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-12 v0.0.88 + +### 관리자 미디어 선택·폴더 UX + +썸네일 전체 클릭이 선택과 미리보기를 겹쳐 쓰기 어렵다. 본문 클릭은 미리보기 모달, 좌측 체크박스만 선택으로 분리했다. 폴더 추가는 사용 빈도가 낮아 상시 입력 대신 모달로 받고, 비어 있지 않은 분류 트리를 위해 폴더 삭제 API와 행 단위 삭제를 추가했다(물리 파일은 건드리지 않고 메타만 미분류로). + ## 2026-05-12 v0.0.87 ### 저장·로그인 버튼 기본 비활성과 글 목록 삭제 아이콘 diff --git a/docs/map.md b/docs/map.md index cb6bbaf..b1200e4 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 | 미디어 관리, 폴더 트리, 복수 선택, 드래그 이동 | +| pages/admin/media/index.vue | 미디어 관리, 폴더 트리·폴더 추가 모달·폴더 삭제, 썸네일 클릭 미리보기 모달, 체크박스 복수 선택·Shift 범위, 드래그로 폴더 이동 | | pages/admin/navigation/index.vue | 메뉴/네비게이션 관리(변경 시에만 메뉴 저장 활성) | | pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 | | pages/admin/tags/new.vue | 태그 생성 | diff --git a/docs/spec.md b/docs/spec.md index 9df5d61..bfcff06 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -388,6 +388,7 @@ components/content/ - `DELETE /admin/api/media` - 업로드 미디어 삭제 - `GET /admin/api/media-folders` - 미디어 폴더 목록 - `POST /admin/api/media-folders` - 미디어 폴더 생성 +- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림) - `POST /admin/api/uploads` - 관리자 이미지 업로드 - `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`) - `POST /admin/api/tags` - 태그 생성 @@ -561,10 +562,10 @@ components/content/ - 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다. - 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다. - 관리자 미디어 화면은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다. -- 관리자는 폴더 트리에서 새 폴더와 하위 폴더를 만들 수 있다. -- 미디어는 Ctrl/Command 클릭으로 복수 선택하고 Shift 클릭으로 범위 선택할 수 있다. +- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`를 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다. +- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 체크박스로 개별 선택한다. Shift+체크로 범위 선택이 가능하다. - 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다. -- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 더블 클릭 시 열리는 상세 모달에서 표시한다. +- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 상세 모달에서 표시한다. - 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다. - 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다. - 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다. diff --git a/docs/update.md b/docs/update.md index d26a36a..7a36f09 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v0.0.88 + +- 관리자 미디어: 썸네일 본문 클릭 시 상세 모달, 좌측 상단 체크박스로 다중 선택(Shift 범위 유지), 툴바 `현재 폴더로 이동` 제거. +- 폴더 추가는 상시 입력 대신 모달로 이름 입력. +- `DELETE /admin/api/media-folders` 및 폴더 행 삭제 UI 추가(삭제 시 해당 분류 메타는 `미분류`로). + ## v0.0.87 - 메인 태그 `정렬 저장`·메뉴 `메뉴 저장`은 서버에서 받은 상태와 비교해 변경이 있을 때만 버튼이 활성화되도록 조정. diff --git a/package.json b/package.json index 403b522..744e3f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.87", + "version": "0.0.88", "private": true, "type": "module", "imports": { diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index 8ec1d2e..b084eea 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -5,7 +5,9 @@ definePageMeta({ const searchText = ref('') const activeFolder = ref('') -const newFolderName = ref('') +const isCreateFolderModalOpen = ref(false) +const createFolderModalName = ref('') +const deletingFolder = ref('') const editingUrl = ref('') const editingName = ref('') const editingCategory = ref('') @@ -120,30 +122,26 @@ const selectFolder = (folder) => { } /** - * 미디어 항목 선택 - * @param {MouseEvent} event - 클릭 이벤트 + * 체크박스로 미디어 선택 토글(Shift 시 범위 추가 선택) * @param {Object} item - 미디어 항목 * @param {number} index - 필터 목록 내 순서 + * @param {MouseEvent} event - 클릭 이벤트 * @returns {void} */ -const selectMediaItem = (event, item, index) => { +const toggleMediaSelection = (item, index, event) => { if (event.shiftKey && lastSelectedIndex.value >= 0) { const startIndex = Math.min(lastSelectedIndex.value, index) const endIndex = Math.max(lastSelectedIndex.value, index) const rangeUrls = filteredMediaItems.value.slice(startIndex, endIndex + 1).map((mediaItem) => mediaItem.url) - selectedMediaUrls.value = [...new Set([...selectedMediaUrls.value, ...rangeUrls])] - return - } - - if (event.metaKey || event.ctrlKey) { - selectedMediaUrls.value = isMediaSelected(item) - ? selectedMediaUrls.value.filter((url) => url !== item.url) - : [...selectedMediaUrls.value, item.url] + const merged = new Set([...selectedMediaUrls.value, ...rangeUrls]) + selectedMediaUrls.value = [...merged] lastSelectedIndex.value = index return } - selectedMediaUrls.value = [item.url] + selectedMediaUrls.value = isMediaSelected(item) + ? selectedMediaUrls.value.filter((url) => url !== item.url) + : [...selectedMediaUrls.value, item.url] lastSelectedIndex.value = index } @@ -188,11 +186,30 @@ const cancelRename = () => { } /** - * 미디어 폴더 생성 + * 폴더 추가 모달을 연다 + * @returns {void} + */ +const openCreateFolderModal = () => { + createFolderModalName.value = '' + errorMessage.value = '' + isCreateFolderModalOpen.value = true +} + +/** + * 폴더 추가 모달을 닫는다 + * @returns {void} + */ +const closeCreateFolderModal = () => { + isCreateFolderModalOpen.value = false + createFolderModalName.value = '' +} + +/** + * 미디어 폴더 생성(모달에서 확인 시) * @returns {Promise} */ -const createFolder = async () => { - const folderName = newFolderName.value.trim() +const submitCreateFolderModal = async () => { + const folderName = createFolderModalName.value.trim() if (!folderName) { return @@ -209,7 +226,7 @@ const createFolder = async () => { } }) - newFolderName.value = '' + closeCreateFolderModal() activeFolder.value = createdFolder.path await refreshMediaFolders() } catch (error) { @@ -217,6 +234,46 @@ const createFolder = async () => { } } +/** + * 미디어 폴더 삭제 + * @param {string} folder - 삭제할 폴더 경로 + * @returns {Promise} + */ +const removeMediaFolder = async (folder) => { + if (!folder || folder === '미분류') { + return + } + + if (!confirm(`"${folder}" 폴더를 삭제할까요? 이 폴더(및 하위 경로)에 속한 미디어는 모두 "미분류"로 옮겨집니다.`)) { + return + } + + deletingFolder.value = folder + errorMessage.value = '' + + try { + await $fetch('/admin/api/media-folders', { + method: 'DELETE', + body: { + path: folder + } + }) + if (activeFolder.value === folder || activeFolder.value.startsWith(`${folder}/`)) { + activeFolder.value = '' + } + selectedMediaUrls.value = [] + lastSelectedIndex.value = -1 + await Promise.all([ + refresh(), + refreshMediaFolders() + ]) + } catch (error) { + errorMessage.value = error?.data?.message || '폴더를 삭제하지 못했습니다.' + } finally { + deletingFolder.value = '' + } +} + /** * 선택한 미디어를 폴더로 이동 * @param {string} folder - 폴더 경로 @@ -408,37 +465,47 @@ const deleteMedia = async (item) => {
- + + +
-
- - + -
+
@@ -456,46 +523,43 @@ const deleteMedia = async (item) => { -
- + + + {{ item.usage.length }} + + + {{ item.name }} + + +

@@ -504,6 +568,47 @@ const deleteMedia = async (item) => { +

+
} 삭제 결과 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const body = await readBody(event) + + return deleteMediaFolder(body?.path || '') +}) diff --git a/server/utils/media-library.js b/server/utils/media-library.js index 7f193fb..2796fc1 100644 --- a/server/utils/media-library.js +++ b/server/utils/media-library.js @@ -99,6 +99,50 @@ export const createMediaFolder = async (path) => { } } +/** + * 미디어 폴더를 삭제하고 해당 폴더(및 하위 경로)에 묶인 미디어 메타는 미분류로 되돌린다. + * @param {string} path - 삭제할 폴더 경로 + * @returns {Promise<{ ok: boolean, path: string }>} 삭제 결과 + */ +export const deleteMediaFolder = async (path) => { + const sql = getPostgresClient() + const normalizedPath = normalizeMediaCategory(path) + + if (!sql) { + throw new Error('DATABASE_REQUIRED') + } + + if (!normalizedPath || normalizedPath === '미분류') { + throw createError({ + statusCode: 400, + message: '이 폴더는 삭제할 수 없습니다.' + }) + } + + const childPrefix = `${normalizedPath}/` + + await sql.begin(async (tx) => { + await tx` + UPDATE media_metadata + SET + category = '미분류', + updated_at = now() + WHERE category = ${normalizedPath} + OR category LIKE ${`${childPrefix}%`} + ` + await tx` + DELETE FROM media_folders + WHERE path = ${normalizedPath} + OR path LIKE ${`${childPrefix}%`} + ` + }) + + return { + ok: true, + path: normalizedPath + } +} + /** * 미디어 파일명 조각을 안전하게 정리 * @param {string} value - 원본 파일명