미디어 폴더 트리 관리 추가
This commit is contained in:
9
db/migrations/007_add_media_folders.sql
Normal file
9
db/migrations/007_add_media_folders.sql
Normal file
@@ -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;
|
||||||
@@ -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
|
## 2026-05-02 v0.0.26
|
||||||
|
|
||||||
### 미디어 카테고리 저장 방식 결정
|
### 미디어 카테고리 저장 방식 결정
|
||||||
|
|||||||
@@ -63,7 +63,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 | 미디어 관리 |
|
| pages/admin/media/index.vue | 미디어 관리, 폴더 트리, 복수 선택, 드래그 이동 |
|
||||||
| 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 | 태그 생성 |
|
||||||
@@ -108,8 +108,10 @@
|
|||||||
| server/routes/admin/api/pages/[id].put.js | 관리자 고정 페이지 수정 API |
|
| server/routes/admin/api/pages/[id].put.js | 관리자 고정 페이지 수정 API |
|
||||||
| server/routes/admin/api/pages/[id].delete.js | 관리자 고정 페이지 삭제 API |
|
| server/routes/admin/api/pages/[id].delete.js | 관리자 고정 페이지 삭제 API |
|
||||||
| server/routes/admin/api/media.get.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.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 |
|
||||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API |
|
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API |
|
||||||
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||||
@@ -130,7 +132,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 | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티 |
|
||||||
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||||
|
|
||||||
@@ -144,6 +146,7 @@
|
|||||||
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
|
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
|
||||||
| 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 | 미디어 폴더 테이블 추가 |
|
||||||
|
|
||||||
## 설정/배포
|
## 설정/배포
|
||||||
|
|
||||||
|
|||||||
24
docs/spec.md
24
docs/spec.md
@@ -184,7 +184,15 @@ components/content/
|
|||||||
| 필드 | 타입 | 설명 |
|
| 필드 | 타입 | 설명 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| url | String | 업로드 미디어 URL |
|
| url | String | 업로드 미디어 URL |
|
||||||
| category | String | 관리자 분류명 |
|
| category | String | 관리자 미디어 폴더 경로 |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
|
### MediaFolders
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| path | String | 미디어 폴더 경로 |
|
||||||
| created_at | DateTime | 생성일 |
|
| created_at | DateTime | 생성일 |
|
||||||
| updated_at | DateTime | 수정일 |
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
@@ -238,8 +246,10 @@ components/content/
|
|||||||
- `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` - 업로드 미디어 목록
|
||||||
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경
|
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경
|
||||||
- `DELETE /admin/api/media` - 업로드 미디어 삭제
|
- `DELETE /admin/api/media` - 업로드 미디어 삭제
|
||||||
|
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
||||||
|
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||||
- `POST /admin/api/uploads` - 관리자 이미지 업로드
|
- `POST /admin/api/uploads` - 관리자 이미지 업로드
|
||||||
- `GET /admin/api/tags` - 태그 목록
|
- `GET /admin/api/tags` - 태그 목록
|
||||||
- `POST /admin/api/tags` - 태그 생성
|
- `POST /admin/api/tags` - 태그 생성
|
||||||
@@ -340,10 +350,12 @@ components/content/
|
|||||||
- 관리자 이미지 업로드 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로 제공한다.
|
||||||
- 관리자 미디어 화면은 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
- 관리자 미디어 화면은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||||
- 관리자 미디어 화면은 카테고리 필터와 미디어별 카테고리 수정을 제공한다.
|
- 관리자는 폴더 트리에서 새 폴더와 하위 폴더를 만들 수 있다.
|
||||||
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 클릭 시 열리는 상세 모달에서 표시한다.
|
- 미디어는 Ctrl/Command 클릭으로 복수 선택하고 Shift 클릭으로 범위 선택할 수 있다.
|
||||||
- 미디어 카테고리는 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별로 저장한다.
|
- 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
|
||||||
|
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 더블 클릭 시 열리는 상세 모달에서 표시한다.
|
||||||
|
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||||
|
|||||||
@@ -10,15 +10,13 @@
|
|||||||
- [ ] 대표 이미지 브라우저 수동 QA: 기존 미디어 선택, 새 업로드, 썸네일 표시, 삭제/변경 확인
|
- [ ] 대표 이미지 브라우저 수동 QA: 기존 미디어 선택, 새 업로드, 썸네일 표시, 삭제/변경 확인
|
||||||
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
|
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
|
||||||
- [ ] 콜아웃, 토글, 임베드 블록 브라우저 수동 QA: `/` 메뉴 선택, 저장/수정 왕복, 공개 렌더링 확인
|
- [ ] 콜아웃, 토글, 임베드 블록 브라우저 수동 QA: `/` 메뉴 선택, 저장/수정 왕복, 공개 렌더링 확인
|
||||||
- [ ] 글 작성 중 자동 저장 브라우저 수동 QA: 새 글/수정 글 복원, 저장 성공 후 삭제, 빈 글 자동 저장 삭제 확인
|
|
||||||
- [ ] 저장 토스트 브라우저 수동 QA: 새 글 저장 후 이동, 수정 저장, 저장 실패, 삭제 실패 상태 확인
|
|
||||||
|
|
||||||
## 2차 관리자 개발
|
## 2차 관리자 개발
|
||||||
|
|
||||||
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
|
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
|
||||||
- [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인
|
- [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인
|
||||||
- [ ] 메뉴/네비게이션 브라우저 수동 QA: 항목 추가, 삭제, 숨김, 정렬, 공개 사이드바 반영 확인
|
- [ ] 메뉴/네비게이션 브라우저 수동 QA: 항목 추가, 삭제, 숨김, 정렬, 공개 사이드바 반영 확인
|
||||||
- [ ] 미디어 라이브러리 카테고리 브라우저 수동 QA: 카테고리 저장, 필터, 파일명 변경 후 유지 확인
|
- [ ] 미디어 라이브러리 폴더 브라우저 수동 QA: 폴더 생성, 하위 폴더 표시, Ctrl/Command 복수 선택, Shift 범위 선택, 드래그 일괄 이동, 상세 모달 폴더 변경 확인
|
||||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||||
|
|
||||||
## 3차 관리자 개발
|
## 3차 관리자 개발
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.27
|
||||||
|
|
||||||
|
- 미디어 폴더 테이블 추가.
|
||||||
|
- 관리자 미디어 폴더 목록/생성 API 추가.
|
||||||
|
- 관리자 미디어 화면을 왼쪽 폴더 트리와 오른쪽 썸네일 갤러리 구조로 수정.
|
||||||
|
- 미디어 Ctrl/Command 클릭 및 Shift 클릭 복수 선택 기능 추가.
|
||||||
|
- 선택 미디어를 폴더로 드래그해 일괄 이동하는 기능 추가.
|
||||||
|
- 미디어 폴더 이동은 실제 파일 경로가 아닌 메타데이터 경로를 갱신하도록 유지.
|
||||||
|
- 패키지 버전을 0.0.27로 갱신.
|
||||||
|
|
||||||
## v0.0.26
|
## v0.0.26
|
||||||
|
|
||||||
- 미디어 메타데이터 테이블 추가.
|
- 미디어 메타데이터 테이블 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.26",
|
"version": "0.0.27",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.26",
|
"version": "0.0.27",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.26",
|
"version": "0.0.27",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,35 +4,70 @@ definePageMeta({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const categoryFilter = ref('')
|
const activeFolder = ref('')
|
||||||
|
const newFolderName = ref('')
|
||||||
const editingUrl = ref('')
|
const editingUrl = ref('')
|
||||||
const editingName = ref('')
|
const editingName = ref('')
|
||||||
const editingCategory = ref('')
|
const editingCategory = ref('')
|
||||||
const deletingUrl = ref('')
|
const deletingUrl = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const selectedMediaUrl = ref('')
|
const selectedMediaUrl = ref('')
|
||||||
|
const selectedMediaUrls = ref([])
|
||||||
|
const lastSelectedIndex = ref(-1)
|
||||||
|
const draggingUrls = ref([])
|
||||||
|
|
||||||
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
|
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
|
||||||
default: () => []
|
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 selectedMedia = computed(() => mediaItems.value.find((item) => item.url === selectedMediaUrl.value) || null)
|
||||||
|
|
||||||
const mediaCategories = computed(() => [...new Set(mediaItems.value
|
const normalizedFolders = computed(() => {
|
||||||
.map((item) => item.category)
|
const folderSet = new Set(['미분류'])
|
||||||
.filter(Boolean))]
|
|
||||||
.sort((left, right) => left.localeCompare(right)))
|
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 filteredMediaItems = computed(() => {
|
||||||
const query = searchText.value.trim().toLowerCase()
|
const query = searchText.value.trim().toLowerCase()
|
||||||
const category = categoryFilter.value
|
const folder = activeFolder.value
|
||||||
|
|
||||||
return mediaItems.value.filter((item) => (!category || item.category === category) && (!query || [
|
return mediaItems.value.filter((item) => {
|
||||||
item.name,
|
const matchesFolder = !folder || item.category === folder || item.category?.startsWith(`${folder}/`)
|
||||||
item.url,
|
const matchesQuery = !query || [
|
||||||
item.category,
|
item.name,
|
||||||
...item.usage.map((usage) => usage.title)
|
item.url,
|
||||||
].some((value) => value.toLowerCase().includes(query))))
|
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`
|
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 - 미디어 항목
|
* @param {Object} item - 미디어 항목
|
||||||
@@ -83,6 +187,100 @@ const cancelRename = () => {
|
|||||||
editingName.value = ''
|
editingName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 폴더 생성
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
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<string>} urls - 이동할 미디어 URL 목록
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
const dropMediaOnFolder = async (folder) => {
|
||||||
|
const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value
|
||||||
|
draggingUrls.value = []
|
||||||
|
await moveMediaToFolder(folder, urls)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미디어 카테고리 저장
|
* 미디어 카테고리 저장
|
||||||
* @returns {Promise<void>} 저장 결과
|
* @returns {Promise<void>} 저장 결과
|
||||||
@@ -98,7 +296,10 @@ const saveMediaCategory = async () => {
|
|||||||
category: editingCategory.value
|
category: editingCategory.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await refresh()
|
await Promise.all([
|
||||||
|
refresh(),
|
||||||
|
refreshMediaFolders()
|
||||||
|
])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error?.data?.message || '카테고리를 저장하지 못했습니다.'
|
errorMessage.value = error?.data?.message || '카테고리를 저장하지 못했습니다.'
|
||||||
}
|
}
|
||||||
@@ -180,50 +381,128 @@ const deleteMedia = async (item) => {
|
|||||||
미디어
|
미디어
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-media__filters flex w-full flex-wrap gap-2 md:w-auto">
|
<input
|
||||||
<select v-model="categoryFilter" class="admin-media__category-filter w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-44">
|
v-model="searchText"
|
||||||
<option value="">전체 카테고리</option>
|
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
|
||||||
<option v-for="category in mediaCategories" :key="category" :value="category">
|
type="search"
|
||||||
{{ category }}
|
placeholder="파일명, 경로, 폴더, 사용처 검색"
|
||||||
</option>
|
>
|
||||||
</select>
|
|
||||||
<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>
|
</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">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-8 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||||
<button
|
<aside class="admin-media__folders rounded border border-line bg-white p-3">
|
||||||
v-for="item in filteredMediaItems"
|
<button
|
||||||
:key="item.url"
|
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__item group relative overflow-hidden border border-line bg-white text-left"
|
:class="!activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openMediaDetail(item)"
|
@click="selectFolder('')"
|
||||||
>
|
@dragover.prevent
|
||||||
<img class="admin-media__image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
@drop.prevent="dropMediaOnFolder('미분류')"
|
||||||
<span
|
|
||||||
v-if="item.usage.length"
|
|
||||||
class="admin-media__usage-badge 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 }}
|
<span>전체 미디어</span>
|
||||||
</span>
|
<span>{{ mediaItems.length }}</span>
|
||||||
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
</button>
|
||||||
{{ item.name }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-else class="admin-media__empty mt-8 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
<div class="admin-media__folder-list mt-3 grid gap-1">
|
||||||
표시할 미디어가 없습니다.
|
<button
|
||||||
</p>
|
v-for="folder in normalizedFolders"
|
||||||
|
:key="folder"
|
||||||
|
class="admin-media__folder-button flex w-full items-center justify-between rounded py-2 pr-3 text-left text-sm hover:bg-surface"
|
||||||
|
:class="activeFolder === folder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
|
||||||
|
:style="{ paddingLeft: `${12 + getFolderDepth(folder) * 14}px` }"
|
||||||
|
type="button"
|
||||||
|
@click="selectFolder(folder)"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="dropMediaOnFolder(folder)"
|
||||||
|
>
|
||||||
|
<span class="admin-media__folder-name min-w-0 truncate">{{ getFolderName(folder) }}</span>
|
||||||
|
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="admin-media__folder-create mt-4 grid gap-2 border-t border-line pt-4" @submit.prevent="createFolder">
|
||||||
|
<label class="admin-media__folder-label text-xs font-semibold text-muted" for="media-folder-name">
|
||||||
|
새 폴더
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="media-folder-name"
|
||||||
|
v-model="newFolderName"
|
||||||
|
class="admin-media__folder-input rounded border border-line px-3 py-2 text-sm"
|
||||||
|
type="text"
|
||||||
|
placeholder="폴더 이름"
|
||||||
|
>
|
||||||
|
<button class="admin-media__folder-submit rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white" type="submit">
|
||||||
|
폴더 추가
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="admin-media__content min-w-0">
|
||||||
|
<div class="admin-media__toolbar flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="admin-media__folder-title text-lg font-semibold text-ink">
|
||||||
|
{{ activeFolder || '전체 미디어' }}
|
||||||
|
</h2>
|
||||||
|
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
|
||||||
|
{{ filteredMediaItems.length }}개 표시
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMediaUrls.length" class="admin-media__selection flex flex-wrap items-center gap-2 rounded border border-line bg-white px-3 py-2 text-xs">
|
||||||
|
<strong class="admin-media__selection-count text-ink">{{ selectedMediaUrls.length }}개 선택됨</strong>
|
||||||
|
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection">
|
||||||
|
선택 해제
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="activeFolder"
|
||||||
|
class="admin-media__selection-move rounded bg-[#15171a] px-2.5 py-1 font-semibold text-white"
|
||||||
|
type="button"
|
||||||
|
@click="moveMediaToFolder(activeFolder)"
|
||||||
|
>
|
||||||
|
현재 폴더로 이동
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in filteredMediaItems"
|
||||||
|
:key="item.url"
|
||||||
|
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition"
|
||||||
|
:class="isMediaSelected(item) ? 'ring-2 ring-[#15171a]' : 'hover:border-[#15171a]'"
|
||||||
|
type="button"
|
||||||
|
draggable="true"
|
||||||
|
@click="selectMediaItem($event, item, index)"
|
||||||
|
@dblclick="openMediaDetail(item)"
|
||||||
|
@dragstart="startMediaDrag($event, item)"
|
||||||
|
>
|
||||||
|
<img class="admin-media__image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||||
|
<span
|
||||||
|
v-if="item.usage.length"
|
||||||
|
class="admin-media__usage-badge 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 }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="isMediaSelected(item)"
|
||||||
|
class="admin-media__selected-badge absolute left-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-full bg-white text-[11px] font-bold text-ink shadow"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
||||||
|
{{ item.name }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="admin-media__empty mt-5 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||||
|
표시할 미디어가 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="selectedMedia"
|
v-if="selectedMedia"
|
||||||
@@ -262,14 +541,14 @@ const deleteMedia = async (item) => {
|
|||||||
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
|
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-media__info-row">
|
<div class="admin-media__info-row">
|
||||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">분류</dt>
|
<dt class="admin-media__info-label text-xs font-semibold text-muted">폴더</dt>
|
||||||
<dd class="admin-media__info-value mt-1">{{ selectedMedia.category }}</dd>
|
<dd class="admin-media__info-value mt-1 break-all">{{ selectedMedia.category }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div class="admin-media__category grid gap-2">
|
<div class="admin-media__category grid gap-2">
|
||||||
<label class="admin-media__category-label text-xs font-semibold text-muted" for="media-category">
|
<label class="admin-media__category-label text-xs font-semibold text-muted" for="media-category">
|
||||||
카테고리
|
폴더
|
||||||
</label>
|
</label>
|
||||||
<div class="admin-media__category-row flex gap-2">
|
<div class="admin-media__category-row flex gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -277,7 +556,7 @@ const deleteMedia = async (item) => {
|
|||||||
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"
|
||||||
type="text"
|
type="text"
|
||||||
list="media-category-options"
|
list="media-folder-options"
|
||||||
placeholder="미분류"
|
placeholder="미분류"
|
||||||
@keydown.enter.prevent="saveMediaCategory"
|
@keydown.enter.prevent="saveMediaCategory"
|
||||||
>
|
>
|
||||||
@@ -285,8 +564,8 @@ const deleteMedia = async (item) => {
|
|||||||
저장
|
저장
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<datalist id="media-category-options">
|
<datalist id="media-folder-options">
|
||||||
<option v-for="category in mediaCategories" :key="category" :value="category" />
|
<option v-for="folder in normalizedFolders" :key="folder" :value="folder" />
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
13
server/routes/admin/api/media-folders.get.js
Normal file
13
server/routes/admin/api/media-folders.get.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||||
|
import { listMediaFolders } from '../../../utils/media-library'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 미디어 폴더 목록 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<Array<string>>} 미디어 폴더 경로 목록
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdminSession(event)
|
||||||
|
|
||||||
|
return listMediaFolders()
|
||||||
|
})
|
||||||
16
server/routes/admin/api/media-folders.post.js
Normal file
16
server/routes/admin/api/media-folders.post.js
Normal file
@@ -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 || '')
|
||||||
|
})
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import { readBody } from 'h3'
|
import { readBody } from 'h3'
|
||||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
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 - 요청 이벤트
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
* @returns {Promise<Object>} 변경된 미디어 항목
|
* @returns {Promise<Object|Array<Object>>} 변경된 미디어 항목
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
requireAdminSession(event)
|
requireAdminSession(event)
|
||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
if (body?.category !== undefined && Array.isArray(body?.urls)) {
|
||||||
|
return updateMediaCategories(body.urls, body.category)
|
||||||
|
}
|
||||||
|
|
||||||
if (body?.category !== undefined) {
|
if (body?.category !== undefined) {
|
||||||
return updateMediaCategory(body?.url, body?.category)
|
return updateMediaCategory(body?.url, body?.category)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,62 @@ const getMediaMetadataMap = async () => {
|
|||||||
const normalizeMediaCategory = (category) => String(category || '')
|
const normalizeMediaCategory = (category) => String(category || '')
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/\/+/g, '/')
|
||||||
|
.replace(/^\/|\/$/g, '')
|
||||||
|| '미분류'
|
|| '미분류'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 폴더 목록 조회
|
||||||
|
* @returns {Promise<Array<string>>} 미디어 폴더 경로 목록
|
||||||
|
*/
|
||||||
|
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 - 원본 파일명
|
* @param {string} value - 원본 파일명
|
||||||
@@ -259,35 +313,56 @@ const moveMediaMetadata = async (currentUrl, nextUrl) => {
|
|||||||
* @returns {Promise<Object>} 수정된 미디어 항목
|
* @returns {Promise<Object>} 수정된 미디어 항목
|
||||||
*/
|
*/
|
||||||
export const updateMediaCategory = async (url, category) => {
|
export const updateMediaCategory = async (url, category) => {
|
||||||
|
const [item] = await updateMediaCategories([url], category)
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 미디어 카테고리 저장
|
||||||
|
* @param {Array<string>} urls - 미디어 URL 목록
|
||||||
|
* @param {string} category - 미디어 카테고리
|
||||||
|
* @returns {Promise<Array<Object>>} 수정된 미디어 항목 목록
|
||||||
|
*/
|
||||||
|
export const updateMediaCategories = async (urls, category) => {
|
||||||
const sql = getPostgresClient()
|
const sql = getPostgresClient()
|
||||||
const mediaPath = resolveMediaPath(url)
|
const normalizedCategory = normalizeMediaCategory(category)
|
||||||
|
|
||||||
if (!sql) {
|
if (!sql) {
|
||||||
throw new Error('DATABASE_REQUIRED')
|
throw new Error('DATABASE_REQUIRED')
|
||||||
}
|
}
|
||||||
|
|
||||||
await sql`
|
await createMediaFolder(normalizedCategory)
|
||||||
INSERT INTO media_metadata (
|
|
||||||
url,
|
|
||||||
category
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
${url},
|
|
||||||
${normalizeMediaCategory(category)}
|
|
||||||
)
|
|
||||||
ON CONFLICT (url) DO UPDATE
|
|
||||||
SET
|
|
||||||
category = EXCLUDED.category,
|
|
||||||
updated_at = now()
|
|
||||||
`
|
|
||||||
|
|
||||||
const item = await createMediaItem(mediaPath)
|
const items = []
|
||||||
|
|
||||||
return {
|
for (const url of [...new Set(urls.filter(Boolean))]) {
|
||||||
...item,
|
const mediaPath = resolveMediaPath(url)
|
||||||
category: normalizeMediaCategory(category),
|
|
||||||
usage: []
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user