v0.0.88: 미디어 선택·미리보기 분리, 폴더 모달·삭제 API

This commit is contained in:
2026-05-12 10:19:37 +09:00
parent 1d9a3e4527
commit 9974e0d137
8 changed files with 253 additions and 75 deletions

View File

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