미디어 폴더 트리 관리 추가

This commit is contained in:
2026-05-02 20:35:28 +09:00
parent dd0a643d73
commit db87542096
13 changed files with 520 additions and 93 deletions

View 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;

View File

@@ -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
### 미디어 카테고리 저장 방식 결정

View File

@@ -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 | 미디어 폴더 테이블 추가 |
## 설정/배포

View File

@@ -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이 깨지지 않도록 파일명 변경과 삭제를 차단한다.

View File

@@ -10,15 +10,13 @@
- [ ] 대표 이미지 브라우저 수동 QA: 기존 미디어 선택, 새 업로드, 썸네일 표시, 삭제/변경 확인
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
- [ ] 콜아웃, 토글, 임베드 블록 브라우저 수동 QA: `/` 메뉴 선택, 저장/수정 왕복, 공개 렌더링 확인
- [ ] 글 작성 중 자동 저장 브라우저 수동 QA: 새 글/수정 글 복원, 저장 성공 후 삭제, 빈 글 자동 저장 삭제 확인
- [ ] 저장 토스트 브라우저 수동 QA: 새 글 저장 후 이동, 수정 저장, 저장 실패, 삭제 실패 상태 확인
## 2차 관리자 개발
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
- [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인
- [ ] 메뉴/네비게이션 브라우저 수동 QA: 항목 추가, 삭제, 숨김, 정렬, 공개 사이드바 반영 확인
- [ ] 미디어 라이브러리 카테고리 브라우저 수동 QA: 카테고리 저장, 필터, 파일명 변경 후 유지 확인
- [ ] 미디어 라이브러리 폴더 브라우저 수동 QA: 폴더 생성, 하위 폴더 표시, Ctrl/Command 복수 선택, Shift 범위 선택, 드래그 일괄 이동, 상세 모달 폴더 변경 확인
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
## 3차 관리자 개발

View File

@@ -1,5 +1,15 @@
# 업데이트 이력
## v0.0.27
- 미디어 폴더 테이블 추가.
- 관리자 미디어 폴더 목록/생성 API 추가.
- 관리자 미디어 화면을 왼쪽 폴더 트리와 오른쪽 썸네일 갤러리 구조로 수정.
- 미디어 Ctrl/Command 클릭 및 Shift 클릭 복수 선택 기능 추가.
- 선택 미디어를 폴더로 드래그해 일괄 이동하는 기능 추가.
- 미디어 폴더 이동은 실제 파일 경로가 아닌 메타데이터 경로를 갱신하도록 유지.
- 패키지 버전을 0.0.27로 갱신.
## v0.0.26
- 미디어 메타데이터 테이블 추가.

4
package-lock.json generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.26",
"version": "0.0.27",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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<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>} 저장 결과
@@ -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) => {
미디어
</h1>
</div>
<div class="admin-media__filters flex w-full flex-wrap gap-2 md:w-auto">
<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">
<option value="">전체 카테고리</option>
<option v-for="category in mediaCategories" :key="category" :value="category">
{{ category }}
</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>
<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>
<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 }}
</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">
<button
v-for="item in filteredMediaItems"
:key="item.url"
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left"
type="button"
@click="openMediaDetail(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"
<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">
<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="!activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
type="button"
@click="selectFolder('')"
@dragover.prevent
@drop.prevent="dropMediaOnFolder('미분류')"
>
{{ item.usage.length }}
</span>
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
{{ item.name }}
</span>
</button>
</div>
<span>전체 미디어</span>
<span>{{ mediaItems.length }}</span>
</button>
<p v-else class="admin-media__empty mt-8 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
표시할 미디어가 없습니다.
</p>
<div class="admin-media__folder-list mt-3 grid gap-1">
<button
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
v-if="selectedMedia"
@@ -262,14 +541,14 @@ const deleteMedia = async (item) => {
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
</div>
<div class="admin-media__info-row">
<dt class="admin-media__info-label text-xs font-semibold text-muted">분류</dt>
<dd class="admin-media__info-value mt-1">{{ selectedMedia.category }}</dd>
<dt class="admin-media__info-label text-xs font-semibold text-muted">폴더</dt>
<dd class="admin-media__info-value mt-1 break-all">{{ selectedMedia.category }}</dd>
</div>
</dl>
<div class="admin-media__category grid gap-2">
<label class="admin-media__category-label text-xs font-semibold text-muted" for="media-category">
카테고리
폴더
</label>
<div class="admin-media__category-row flex gap-2">
<input
@@ -277,7 +556,7 @@ const deleteMedia = async (item) => {
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) => {
저장
</button>
</div>
<datalist id="media-category-options">
<option v-for="category in mediaCategories" :key="category" :value="category" />
<datalist id="media-folder-options">
<option v-for="folder in normalizedFolders" :key="folder" :value="folder" />
</datalist>
</div>

View 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()
})

View 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 || '')
})

View File

@@ -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<Object>} 변경된 미디어 항목
* @returns {Promise<Object|Array<Object>>} 변경된 미디어 항목
*/
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)
}

View File

@@ -43,8 +43,62 @@ const getMediaMetadataMap = async () => {
const normalizeMediaCategory = (category) => String(category || '')
.trim()
.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 - 원본 파일명
@@ -259,35 +313,56 @@ const moveMediaMetadata = async (currentUrl, nextUrl) => {
* @returns {Promise<Object>} 수정된 미디어 항목
*/
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 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
}
/**