diff --git a/db/migrations/007_add_media_folders.sql b/db/migrations/007_add_media_folders.sql new file mode 100644 index 0000000..31dcbb8 --- /dev/null +++ b/db/migrations/007_add_media_folders.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS media_folders ( + path TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO media_folders (path) +VALUES ('미분류') +ON CONFLICT (path) DO NOTHING; diff --git a/docs/history.md b/docs/history.md index 5d6eb84..bb8e7d7 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,13 @@ # 의사결정 이력 +## 2026-05-02 v0.0.27 + +### 미디어 폴더 트리 관리 방식 결정 + +미디어 폴더는 워드프레스 플러그인형 폴더 UX처럼 왼쪽 트리에서 만들고 선택하지만, 실제 업로드 파일 경로는 이동하지 않는다. 이미 게시물과 페이지에 저장된 이미지 URL이 깨지는 일을 막기 위해 폴더 이동은 `media_metadata.category` 값을 경로 문자열로 갱신하는 방식으로 처리한다. + +빈 폴더도 남길 수 있어야 하므로 `media_folders` 테이블을 별도로 둔다. 다만 미디어 사용 여부와 공개 렌더링은 계속 URL 기준으로 판단하며, Ctrl/Command 및 Shift 복수 선택과 드래그 이동은 선택된 URL 목록의 메타데이터만 일괄 변경한다. + ## 2026-05-02 v0.0.26 ### 미디어 카테고리 저장 방식 결정 diff --git a/docs/map.md b/docs/map.md index 356eafd..3c57056 100644 --- a/docs/map.md +++ b/docs/map.md @@ -63,7 +63,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 | 미디어 관리, 폴더 트리, 복수 선택, 드래그 이동 | | pages/admin/navigation/index.vue | 메뉴/네비게이션 관리 | | pages/admin/tags/index.vue | 태그 관리 | | pages/admin/tags/new.vue | 태그 생성 | @@ -108,8 +108,10 @@ | server/routes/admin/api/pages/[id].put.js | 관리자 고정 페이지 수정 API | | server/routes/admin/api/pages/[id].delete.js | 관리자 고정 페이지 삭제 API | | server/routes/admin/api/media.get.js | 관리자 미디어 목록 API | -| server/routes/admin/api/media.put.js | 관리자 미디어 파일명 변경 API | +| server/routes/admin/api/media.put.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.post.js | 관리자 미디어 폴더 생성 API | | server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API | | server/routes/admin/api/tags.get.js | 관리자 태그 목록 API | | server/routes/admin/api/tags.post.js | 관리자 태그 생성 API | @@ -130,7 +132,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 | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티 | | server/repositories/postgres-client.js | PostgreSQL 클라이언트 | | server/repositories/content-repository.js | 콘텐츠 조회 저장소 | @@ -144,6 +146,7 @@ | db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 | | db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 | | db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 | +| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 | ## 설정/배포 diff --git a/docs/spec.md b/docs/spec.md index fa7667c..0fe7efe 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -184,7 +184,15 @@ components/content/ | 필드 | 타입 | 설명 | |------|------|------| | url | String | 업로드 미디어 URL | -| category | String | 관리자 분류명 | +| category | String | 관리자 미디어 폴더 경로 | +| created_at | DateTime | 생성일 | +| updated_at | DateTime | 수정일 | + +### MediaFolders + +| 필드 | 타입 | 설명 | +|------|------|------| +| path | String | 미디어 폴더 경로 | | created_at | DateTime | 생성일 | | updated_at | DateTime | 수정일 | @@ -238,8 +246,10 @@ components/content/ - `PUT /admin/api/pages/:id` - 고정 페이지 수정 - `DELETE /admin/api/pages/:id` - 고정 페이지 삭제 - `GET /admin/api/media` - 업로드 미디어 목록 -- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 +- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경 - `DELETE /admin/api/media` - 업로드 미디어 삭제 +- `GET /admin/api/media-folders` - 미디어 폴더 목록 +- `POST /admin/api/media-folders` - 미디어 폴더 생성 - `POST /admin/api/uploads` - 관리자 이미지 업로드 - `GET /admin/api/tags` - 태그 목록 - `POST /admin/api/tags` - 태그 생성 @@ -340,10 +350,12 @@ components/content/ - 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다. - 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다. - 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다. -- 관리자 미디어 화면은 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다. -- 관리자 미디어 화면은 카테고리 필터와 미디어별 카테고리 수정을 제공한다. -- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 클릭 시 열리는 상세 모달에서 표시한다. -- 미디어 카테고리는 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별로 저장한다. +- 관리자 미디어 화면은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다. +- 관리자는 폴더 트리에서 새 폴더와 하위 폴더를 만들 수 있다. +- 미디어는 Ctrl/Command 클릭으로 복수 선택하고 Shift 클릭으로 범위 선택할 수 있다. +- 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다. +- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 더블 클릭 시 열리는 상세 모달에서 표시한다. +- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다. - 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다. - 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다. - 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다. diff --git a/docs/todo.md b/docs/todo.md index 76221a1..5b22323 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -10,15 +10,13 @@ - [ ] 대표 이미지 브라우저 수동 QA: 기존 미디어 선택, 새 업로드, 썸네일 표시, 삭제/변경 확인 - [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인 - [ ] 콜아웃, 토글, 임베드 블록 브라우저 수동 QA: `/` 메뉴 선택, 저장/수정 왕복, 공개 렌더링 확인 -- [ ] 글 작성 중 자동 저장 브라우저 수동 QA: 새 글/수정 글 복원, 저장 성공 후 삭제, 빈 글 자동 저장 삭제 확인 -- [ ] 저장 토스트 브라우저 수동 QA: 새 글 저장 후 이동, 수정 저장, 저장 실패, 삭제 실패 상태 확인 ## 2차 관리자 개발 - [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인 - [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인 - [ ] 메뉴/네비게이션 브라우저 수동 QA: 항목 추가, 삭제, 숨김, 정렬, 공개 사이드바 반영 확인 -- [ ] 미디어 라이브러리 카테고리 브라우저 수동 QA: 카테고리 저장, 필터, 파일명 변경 후 유지 확인 +- [ ] 미디어 라이브러리 폴더 브라우저 수동 QA: 폴더 생성, 하위 폴더 표시, Ctrl/Command 복수 선택, Shift 범위 선택, 드래그 일괄 이동, 상세 모달 폴더 변경 확인 - [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적 ## 3차 관리자 개발 diff --git a/docs/update.md b/docs/update.md index 95d1b9f..8209e98 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,15 @@ # 업데이트 이력 +## v0.0.27 + +- 미디어 폴더 테이블 추가. +- 관리자 미디어 폴더 목록/생성 API 추가. +- 관리자 미디어 화면을 왼쪽 폴더 트리와 오른쪽 썸네일 갤러리 구조로 수정. +- 미디어 Ctrl/Command 클릭 및 Shift 클릭 복수 선택 기능 추가. +- 선택 미디어를 폴더로 드래그해 일괄 이동하는 기능 추가. +- 미디어 폴더 이동은 실제 파일 경로가 아닌 메타데이터 경로를 갱신하도록 유지. +- 패키지 버전을 0.0.27로 갱신. + ## v0.0.26 - 미디어 메타데이터 테이블 추가. diff --git a/package-lock.json b/package-lock.json index 931b1b9..f56739f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "0.0.26", + "version": "0.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "0.0.26", + "version": "0.0.27", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 9da5a05..bc29192 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.26", + "version": "0.0.27", "private": true, "type": "module", "scripts": { diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index c6734fb..8ec1d2e 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -4,35 +4,70 @@ definePageMeta({ }) const searchText = ref('') -const categoryFilter = ref('') +const activeFolder = ref('') +const newFolderName = ref('') const editingUrl = ref('') const editingName = ref('') const editingCategory = ref('') const deletingUrl = ref('') const errorMessage = ref('') const selectedMediaUrl = ref('') +const selectedMediaUrls = ref([]) +const lastSelectedIndex = ref(-1) +const draggingUrls = ref([]) const { data: mediaItems, refresh } = await useFetch('/admin/api/media', { default: () => [] }) +const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', { + default: () => ['미분류'] +}) + const selectedMedia = computed(() => mediaItems.value.find((item) => item.url === selectedMediaUrl.value) || null) -const mediaCategories = computed(() => [...new Set(mediaItems.value - .map((item) => item.category) - .filter(Boolean))] - .sort((left, right) => left.localeCompare(right))) +const normalizedFolders = computed(() => { + const folderSet = new Set(['미분류']) + + mediaFolders.value.forEach((folder) => { + String(folder || '').split('/').filter(Boolean).reduce((parentPath, segment) => { + const nextPath = parentPath ? `${parentPath}/${segment}` : segment + folderSet.add(nextPath) + return nextPath + }, '') + }) + + mediaItems.value.forEach((item) => { + String(item.category || '미분류').split('/').filter(Boolean).reduce((parentPath, segment) => { + const nextPath = parentPath ? `${parentPath}/${segment}` : segment + folderSet.add(nextPath) + return nextPath + }, '') + }) + + return [...folderSet].sort((left, right) => left.localeCompare(right)) +}) + +const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts, folder) => { + counts[folder] = mediaItems.value.filter((item) => item.category === folder || item.category?.startsWith(`${folder}/`)).length + return counts +}, {})) const filteredMediaItems = computed(() => { const query = searchText.value.trim().toLowerCase() - const category = categoryFilter.value + const folder = activeFolder.value - return mediaItems.value.filter((item) => (!category || item.category === category) && (!query || [ - item.name, - item.url, - item.category, - ...item.usage.map((usage) => usage.title) - ].some((value) => value.toLowerCase().includes(query)))) + return mediaItems.value.filter((item) => { + const matchesFolder = !folder || item.category === folder || item.category?.startsWith(`${folder}/`) + const matchesQuery = !query || [ + item.name, + item.url, + item.category, + ...item.usage.map((usage) => usage.title) + ].some((value) => String(value || '').toLowerCase().includes(query)) + + return matchesFolder && matchesQuery + }) }) /** @@ -52,6 +87,75 @@ const formatFileSize = (size) => { return `${(size / 1024 / 1024).toFixed(1)} MB` } +/** + * 폴더 경로의 표시 이름 조회 + * @param {string} folder - 폴더 경로 + * @returns {string} 표시 이름 + */ +const getFolderName = (folder) => folder.split('/').filter(Boolean).pop() || '미분류' + +/** + * 폴더 깊이 조회 + * @param {string} folder - 폴더 경로 + * @returns {number} 폴더 깊이 + */ +const getFolderDepth = (folder) => Math.max(folder.split('/').filter(Boolean).length - 1, 0) + +/** + * 미디어 선택 여부 확인 + * @param {Object} item - 미디어 항목 + * @returns {boolean} 선택 여부 + */ +const isMediaSelected = (item) => selectedMediaUrls.value.includes(item.url) + +/** + * 폴더 선택 + * @param {string} folder - 폴더 경로 + * @returns {void} + */ +const selectFolder = (folder) => { + activeFolder.value = folder + selectedMediaUrls.value = [] + lastSelectedIndex.value = -1 +} + +/** + * 미디어 항목 선택 + * @param {MouseEvent} event - 클릭 이벤트 + * @param {Object} item - 미디어 항목 + * @param {number} index - 필터 목록 내 순서 + * @returns {void} + */ +const selectMediaItem = (event, item, index) => { + 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] + lastSelectedIndex.value = index + return + } + + selectedMediaUrls.value = [item.url] + lastSelectedIndex.value = index +} + +/** + * 선택된 미디어 해제 + * @returns {void} + */ +const clearMediaSelection = () => { + selectedMediaUrls.value = [] + lastSelectedIndex.value = -1 +} + /** * 미디어 상세 모달 열기 * @param {Object} item - 미디어 항목 @@ -83,6 +187,100 @@ const cancelRename = () => { editingName.value = '' } +/** + * 미디어 폴더 생성 + * @returns {Promise} + */ +const createFolder = async () => { + const folderName = newFolderName.value.trim() + + if (!folderName) { + return + } + + errorMessage.value = '' + + try { + const folderPath = activeFolder.value ? `${activeFolder.value}/${folderName}` : folderName + const createdFolder = await $fetch('/admin/api/media-folders', { + method: 'POST', + body: { + path: folderPath + } + }) + + newFolderName.value = '' + activeFolder.value = createdFolder.path + await refreshMediaFolders() + } catch (error) { + errorMessage.value = error?.data?.message || '폴더를 만들지 못했습니다.' + } +} + +/** + * 선택한 미디어를 폴더로 이동 + * @param {string} folder - 폴더 경로 + * @param {Array} urls - 이동할 미디어 URL 목록 + * @returns {Promise} + */ +const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => { + const targetUrls = [...new Set(urls.filter(Boolean))] + + if (!targetUrls.length) { + return + } + + errorMessage.value = '' + + try { + await $fetch('/admin/api/media', { + method: 'PUT', + body: { + urls: targetUrls, + category: folder || '미분류' + } + }) + await Promise.all([ + refresh(), + refreshMediaFolders() + ]) + activeFolder.value = folder || '미분류' + clearMediaSelection() + } catch (error) { + errorMessage.value = error?.data?.message || '미디어 폴더를 변경하지 못했습니다.' + } +} + +/** + * 미디어 드래그 시작 + * @param {DragEvent} event - 드래그 이벤트 + * @param {Object} item - 미디어 항목 + * @returns {void} + */ +const startMediaDrag = (event, item) => { + if (!isMediaSelected(item)) { + selectedMediaUrls.value = [item.url] + } + + draggingUrls.value = [...selectedMediaUrls.value] + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', draggingUrls.value.join('\n')) + } +} + +/** + * 폴더로 미디어 드롭 + * @param {string} folder - 폴더 경로 + * @returns {Promise} + */ +const dropMediaOnFolder = async (folder) => { + const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value + draggingUrls.value = [] + await moveMediaToFolder(folder, urls) +} + /** * 미디어 카테고리 저장 * @returns {Promise} 저장 결과 @@ -98,7 +296,10 @@ const saveMediaCategory = async () => { category: editingCategory.value } }) - await refresh() + await Promise.all([ + refresh(), + refreshMediaFolders() + ]) } catch (error) { errorMessage.value = error?.data?.message || '카테고리를 저장하지 못했습니다.' } @@ -180,50 +381,128 @@ const deleteMedia = async (item) => { 미디어 -
- - -
+

{{ errorMessage }}

-
- -
+ 전체 미디어 + {{ mediaItems.length }} + -

- 표시할 미디어가 없습니다. -

+
+ +
+ +
+ + + +
+ + +
+
+
+

+ {{ activeFolder || '전체 미디어' }} +

+

+ {{ filteredMediaItems.length }}개 표시 +

+
+
+ {{ selectedMediaUrls.length }}개 선택됨 + + +
+
+ +
+ +
+ +

+ 표시할 미디어가 없습니다. +

+
+
{
{{ formatFileSize(selectedMedia.size) }}
-
분류
-
{{ selectedMedia.category }}
+
폴더
+
{{ selectedMedia.category }}
{ v-model="editingCategory" class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm" type="text" - list="media-category-options" + list="media-folder-options" placeholder="미분류" @keydown.enter.prevent="saveMediaCategory" > @@ -285,8 +564,8 @@ const deleteMedia = async (item) => { 저장
- -
diff --git a/server/routes/admin/api/media-folders.get.js b/server/routes/admin/api/media-folders.get.js new file mode 100644 index 0000000..ff35dbb --- /dev/null +++ b/server/routes/admin/api/media-folders.get.js @@ -0,0 +1,13 @@ +import { requireAdminSession } from '../../../utils/admin-auth' +import { listMediaFolders } from '../../../utils/media-library' + +/** + * 관리자 미디어 폴더 목록 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise>} 미디어 폴더 경로 목록 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + return listMediaFolders() +}) diff --git a/server/routes/admin/api/media-folders.post.js b/server/routes/admin/api/media-folders.post.js new file mode 100644 index 0000000..6972832 --- /dev/null +++ b/server/routes/admin/api/media-folders.post.js @@ -0,0 +1,16 @@ +import { readBody } from 'h3' +import { requireAdminSession } from '../../../utils/admin-auth' +import { createMediaFolder } from '../../../utils/media-library' + +/** + * 관리자 미디어 폴더 생성 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise<{ path: string }>} 생성된 미디어 폴더 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const body = await readBody(event) + + return createMediaFolder(body?.path || '') +}) diff --git a/server/routes/admin/api/media.put.js b/server/routes/admin/api/media.put.js index ad27588..154936c 100644 --- a/server/routes/admin/api/media.put.js +++ b/server/routes/admin/api/media.put.js @@ -1,17 +1,21 @@ import { readBody } from 'h3' import { requireAdminSession } from '../../../utils/admin-auth' -import { renameMediaItem, updateMediaCategory } from '../../../utils/media-library' +import { renameMediaItem, updateMediaCategories, updateMediaCategory } from '../../../utils/media-library' /** - * 관리자 미디어 파일명 변경 API + * 관리자 미디어 파일명 및 폴더 변경 API * @param {import('h3').H3Event} event - 요청 이벤트 - * @returns {Promise} 변경된 미디어 항목 + * @returns {Promise>} 변경된 미디어 항목 */ export default defineEventHandler(async (event) => { requireAdminSession(event) const body = await readBody(event) + if (body?.category !== undefined && Array.isArray(body?.urls)) { + return updateMediaCategories(body.urls, body.category) + } + if (body?.category !== undefined) { return updateMediaCategory(body?.url, body?.category) } diff --git a/server/utils/media-library.js b/server/utils/media-library.js index 4fd0bb0..7f193fb 100644 --- a/server/utils/media-library.js +++ b/server/utils/media-library.js @@ -43,8 +43,62 @@ const getMediaMetadataMap = async () => { const normalizeMediaCategory = (category) => String(category || '') .trim() .replace(/\s+/g, ' ') + .replace(/\/+/g, '/') + .replace(/^\/|\/$/g, '') || '미분류' +/** + * 미디어 폴더 목록 조회 + * @returns {Promise>} 미디어 폴더 경로 목록 + */ +export const listMediaFolders = async () => { + const sql = getPostgresClient() + const items = await readMediaDirectory(uploadRoot) + const metadataMap = await getMediaMetadataMap() + const defaultCategories = items.map((item) => metadataMap[item.url]?.category || item.category) + + if (!sql) { + return [...new Set(['미분류', ...defaultCategories])].sort((left, right) => left.localeCompare(right)) + } + + const rows = await sql` + SELECT path + FROM media_folders + ORDER BY path ASC + ` + + return [...new Set([ + '미분류', + ...rows.map((row) => row.path), + ...defaultCategories + ])].sort((left, right) => left.localeCompare(right)) +} + +/** + * 미디어 폴더 생성 + * @param {string} path - 폴더 경로 + * @returns {Promise<{ path: string }>} 생성된 폴더 + */ +export const createMediaFolder = async (path) => { + const sql = getPostgresClient() + const normalizedPath = normalizeMediaCategory(path) + + if (!sql) { + throw new Error('DATABASE_REQUIRED') + } + + await sql` + INSERT INTO media_folders (path) + VALUES (${normalizedPath}) + ON CONFLICT (path) DO UPDATE + SET updated_at = now() + ` + + return { + path: normalizedPath + } +} + /** * 미디어 파일명 조각을 안전하게 정리 * @param {string} value - 원본 파일명 @@ -259,35 +313,56 @@ const moveMediaMetadata = async (currentUrl, nextUrl) => { * @returns {Promise} 수정된 미디어 항목 */ export const updateMediaCategory = async (url, category) => { + const [item] = await updateMediaCategories([url], category) + + return item +} + +/** + * 여러 미디어 카테고리 저장 + * @param {Array} urls - 미디어 URL 목록 + * @param {string} category - 미디어 카테고리 + * @returns {Promise>} 수정된 미디어 항목 목록 + */ +export const updateMediaCategories = async (urls, category) => { const sql = getPostgresClient() - const mediaPath = resolveMediaPath(url) + const normalizedCategory = normalizeMediaCategory(category) if (!sql) { throw new Error('DATABASE_REQUIRED') } - await sql` - INSERT INTO media_metadata ( - url, - category - ) - VALUES ( - ${url}, - ${normalizeMediaCategory(category)} - ) - ON CONFLICT (url) DO UPDATE - SET - category = EXCLUDED.category, - updated_at = now() - ` + await createMediaFolder(normalizedCategory) - const item = await createMediaItem(mediaPath) + const items = [] - return { - ...item, - category: normalizeMediaCategory(category), - usage: [] + for (const url of [...new Set(urls.filter(Boolean))]) { + const mediaPath = resolveMediaPath(url) + + await sql` + INSERT INTO media_metadata ( + url, + category + ) + VALUES ( + ${url}, + ${normalizedCategory} + ) + ON CONFLICT (url) DO UPDATE + SET + category = EXCLUDED.category, + updated_at = now() + ` + + const item = await createMediaItem(mediaPath) + items.push({ + ...item, + category: normalizedCategory, + usage: [] + }) } + + return items } /**