v0.0.88: 미디어 선택·미리보기 분리, 폴더 모달·삭제 API
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-12 v0.0.88
|
||||
|
||||
### 관리자 미디어 선택·폴더 UX
|
||||
|
||||
썸네일 전체 클릭이 선택과 미리보기를 겹쳐 쓰기 어렵다. 본문 클릭은 미리보기 모달, 좌측 체크박스만 선택으로 분리했다. 폴더 추가는 사용 빈도가 낮아 상시 입력 대신 모달로 받고, 비어 있지 않은 분류 트리를 위해 폴더 삭제 API와 행 단위 삭제를 추가했다(물리 파일은 건드리지 않고 메타만 미분류로).
|
||||
|
||||
## 2026-05-12 v0.0.87
|
||||
|
||||
### 저장·로그인 버튼 기본 비활성과 글 목록 삭제 아이콘
|
||||
|
||||
@@ -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 | 태그 생성 |
|
||||
|
||||
@@ -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을 기준으로 표시한다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.88
|
||||
|
||||
- 관리자 미디어: 썸네일 본문 클릭 시 상세 모달, 좌측 상단 체크박스로 다중 선택(Shift 범위 유지), 툴바 `현재 폴더로 이동` 제거.
|
||||
- 폴더 추가는 상시 입력 대신 모달로 이름 입력.
|
||||
- `DELETE /admin/api/media-folders` 및 폴더 행 삭제 UI 추가(삭제 시 해당 분류 메타는 `미분류`로).
|
||||
|
||||
## v0.0.87
|
||||
|
||||
- 메인 태그 `정렬 저장`·메뉴 `메뉴 저장`은 서버에서 받은 상태와 비교해 변경이 있을 때만 버튼이 활성화되도록 조정.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.87",
|
||||
"version": "0.0.88",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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) => {
|
||||
</button>
|
||||
|
||||
<div class="admin-media__folder-list mt-3 grid gap-1">
|
||||
<button
|
||||
<div
|
||||
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)"
|
||||
class="admin-media__folder-row flex items-stretch rounded"
|
||||
:class="activeFolder === folder ? 'bg-[#15171a] text-white' : 'text-ink hover:bg-surface'"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
class="admin-media__folder-button flex min-w-0 flex-1 items-center justify-between rounded py-2 pr-3 text-left text-sm"
|
||||
: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>
|
||||
<button
|
||||
v-if="folder !== '미분류'"
|
||||
class="admin-media__folder-delete mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded text-current opacity-40 transition hover:opacity-100 hover:text-red-300 disabled:opacity-25"
|
||||
type="button"
|
||||
:disabled="deletingFolder === folder"
|
||||
:aria-label="`${folder} 폴더 삭제`"
|
||||
@click.stop="removeMediaFolder(folder)"
|
||||
>
|
||||
<svg class="size-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</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="폴더 이름"
|
||||
<div class="admin-media__folder-actions mt-4 border-t border-line pt-4">
|
||||
<button
|
||||
class="admin-media__folder-add w-full rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white"
|
||||
type="button"
|
||||
@click="openCreateFolderModal"
|
||||
>
|
||||
<button class="admin-media__folder-submit rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white" type="submit">
|
||||
폴더 추가
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="admin-media__content min-w-0">
|
||||
@@ -456,46 +523,43 @@ const deleteMedia = async (item) => {
|
||||
<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
|
||||
<div
|
||||
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"
|
||||
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]"
|
||||
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"
|
||||
<label class="admin-media__select-label absolute left-1.5 top-1.5 z-10 grid size-6 cursor-pointer place-items-center rounded border border-line bg-white/95 shadow-sm hover:bg-white">
|
||||
<span class="sr-only">{{ item.name }} 선택</span>
|
||||
<input
|
||||
class="admin-media__select-checkbox size-3.5 rounded border-line text-[#15171a] focus:ring-[#15171a]"
|
||||
type="checkbox"
|
||||
:checked="isMediaSelected(item)"
|
||||
@click.stop.prevent="toggleMediaSelection(item, index, $event)"
|
||||
>
|
||||
</label>
|
||||
<button
|
||||
class="admin-media__thumb relative flex w-full flex-col text-left outline-none"
|
||||
type="button"
|
||||
@click="openMediaDetail(item)"
|
||||
>
|
||||
{{ 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>
|
||||
<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 pointer-events-none 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 class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
@@ -504,6 +568,47 @@ const deleteMedia = async (item) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCreateFolderModalOpen"
|
||||
class="admin-media__folder-create-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="media-folder-modal-title"
|
||||
@click.self="closeCreateFolderModal"
|
||||
>
|
||||
<section class="admin-media__folder-create-panel w-full max-w-sm rounded border border-line bg-white p-5 shadow-xl">
|
||||
<h3 id="media-folder-modal-title" class="text-lg font-semibold text-ink">
|
||||
새 폴더
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ activeFolder ? `${activeFolder} 아래에 하위 폴더를 만듭니다.` : '최상위 폴더를 만듭니다.' }}
|
||||
</p>
|
||||
<label class="mt-4 grid gap-1.5 text-sm">
|
||||
<span class="text-xs font-semibold text-muted">폴더 이름</span>
|
||||
<input
|
||||
v-model="createFolderModalName"
|
||||
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
||||
type="text"
|
||||
placeholder="폴더 이름"
|
||||
@keydown.enter.prevent="submitCreateFolderModal"
|
||||
>
|
||||
</label>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closeCreateFolderModal">
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="!createFolderModalName.trim()"
|
||||
@click="submitCreateFolderModal"
|
||||
>
|
||||
만들기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedMedia"
|
||||
class="admin-media__modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||
|
||||
16
server/routes/admin/api/media-folders.delete.js
Normal file
16
server/routes/admin/api/media-folders.delete.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { readBody } from 'h3'
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { deleteMediaFolder } from '../../../utils/media-library'
|
||||
|
||||
/**
|
||||
* 관리자 미디어 폴더 삭제 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: boolean, path: string }>} 삭제 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
return deleteMediaFolder(body?.path || '')
|
||||
})
|
||||
@@ -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 - 원본 파일명
|
||||
|
||||
Reference in New Issue
Block a user