v0.0.88: 미디어 선택·미리보기 분리, 폴더 모달·삭제 API
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user