미디어 폴더 트리 관리 추가
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user