미디어 선택 삭제와 모바일 목차 정리 v1.5.14

This commit is contained in:
2026-05-27 15:43:27 +09:00
parent cb92b32f9c
commit ac57ff458d
12 changed files with 266 additions and 24 deletions

View File

@@ -29,6 +29,9 @@ const selectedMediaUrl = ref('')
const selectedMediaUrls = ref([])
const lastSelectedIndex = ref(-1)
const draggingUrls = ref([])
const uploadInputRef = ref(null)
const uploadingFiles = ref(false)
const deletingSelected = ref(false)
const { toast, showToast } = useAdminToast()
@@ -239,6 +242,22 @@ const filteredMediaItems = computed(() => {
})
})
const filteredMediaUrls = computed(() => filteredMediaItems.value.map((item) => item.url))
const isAllFilteredMediaSelected = computed(() => {
if (!filteredMediaUrls.value.length) {
return false
}
return filteredMediaUrls.value.every((url) => selectedMediaUrls.value.includes(url))
})
const selectedDeletableMediaItems = computed(() => {
const selectedUrlSet = new Set(selectedMediaUrls.value)
return filteredMediaItems.value.filter((item) => selectedUrlSet.has(item.url) && !isMediaItemLocked(item))
})
/**
* 파일 크기 표시 문자열 생성
* @param {number} size - 파일 크기
@@ -323,6 +342,99 @@ const clearMediaSelection = () => {
lastSelectedIndex.value = -1
}
/**
* 현재 필터링된 미디어 전체 선택을 토글한다.
* @returns {void}
*/
const toggleFilteredMediaSelection = () => {
if (!filteredMediaUrls.value.length) {
return
}
if (isAllFilteredMediaSelected.value) {
const filteredUrlSet = new Set(filteredMediaUrls.value)
selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => !filteredUrlSet.has(url))
lastSelectedIndex.value = -1
return
}
selectedMediaUrls.value = [...new Set([
...selectedMediaUrls.value,
...filteredMediaUrls.value
])]
lastSelectedIndex.value = filteredMediaItems.value.length - 1
}
/**
* 파일 직접 업로드 선택창을 연다.
* @returns {void}
*/
const openUploadFilePicker = () => {
if (activeTab.value !== 'library') {
return
}
uploadInputRef.value?.click()
}
/**
* 선택된 파일을 미디어 라이브러리에 업로드한다.
* @param {Event} event - 파일 입력 변경 이벤트
* @returns {Promise<void>}
*/
const uploadSelectedFiles = async (event) => {
if (activeTab.value !== 'library') {
return
}
const input = event.target
if (!(input instanceof HTMLInputElement) || !input.files?.length) {
return
}
const files = Array.from(input.files)
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file)
})
uploadingFiles.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
const uploadedUrls = Array.isArray(result?.files)
? result.files.map((file) => file.url).filter(Boolean)
: []
if (activeFolder.value && uploadedUrls.length) {
await $fetch('/admin/api/media', {
method: 'PUT',
body: {
urls: uploadedUrls,
category: activeFolder.value
}
})
}
await Promise.all([
refresh(),
refreshMediaFolders()
])
selectedMediaUrls.value = uploadedUrls
showToast('success', `${files.length}개 파일을 추가했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '파일을 업로드하지 못했습니다.')
} finally {
uploadingFiles.value = false
input.value = ''
}
}
/**
* 미디어 상세 모달 열기
* @param {Object} item - 미디어 항목
@@ -605,6 +717,62 @@ const deleteMedia = async (item) => {
deletingUrl.value = ''
}
}
/**
* 선택한 미디어 중 삭제 가능한 항목을 한 번에 삭제한다.
* @returns {Promise<void>}
*/
const deleteSelectedMedia = async () => {
const items = selectedDeletableMediaItems.value
const lockedCount = selectedMediaUrls.value.length - items.length
if (!items.length) {
showToast('error', '삭제할 수 있는 선택 항목이 없습니다.')
return
}
const confirmMessage = lockedCount > 0
? `${items.length}개 파일을 삭제할까요? 잠긴 ${lockedCount}개 항목은 제외됩니다.`
: `${items.length}개 파일을 삭제할까요?`
if (!confirm(confirmMessage)) {
return
}
deletingSelected.value = true
try {
for (const item of items) {
await $fetch('/admin/api/media', {
method: 'DELETE',
body: {
url: item.url
}
})
}
closeMediaDetail()
clearMediaSelection()
await Promise.all([
refresh(),
refreshMediaFolders()
])
showToast('success', `${items.length}개 파일을 삭제했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '선택한 파일을 삭제하지 못했습니다.')
} finally {
deletingSelected.value = false
}
}
watch(filteredMediaUrls, (urls) => {
const visibleUrlSet = new Set(urls)
selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => visibleUrlSet.has(url))
if (!selectedMediaUrls.value.length) {
lastSelectedIndex.value = -1
}
})
</script>
<template>
@@ -637,12 +805,37 @@ const deleteMedia = async (item) => {
</button>
</div>
</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="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'"
>
<div class="admin-media__header-actions flex min-w-0 flex-wrap items-center gap-2">
<label class="admin-media__search relative">
<span class="sr-only">미디어 검색</span>
<svg class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#8e9cac]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
<input
v-model="searchText"
class="admin-media__search-input h-10 w-full rounded border border-line bg-white pl-9 pr-3 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] hover:border-[#c8ced3] focus:border-[#8e9cac] md:w-72"
type="search"
placeholder="미디어 검색"
>
</label>
<button
v-if="activeTab === 'library'"
class="admin-media__upload-button h-10 shrink-0 rounded bg-[#15171a] px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="uploadingFiles"
@click="openUploadFilePicker"
>
{{ uploadingFiles ? '추가 중' : '파일 추가' }}
</button>
<input
ref="uploadInputRef"
class="hidden"
type="file"
multiple
@change="uploadSelectedFiles"
>
</div>
</div>
<div
v-if="activeTab === 'library'"
@@ -768,11 +961,30 @@ const deleteMedia = async (item) => {
{{ 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">
선택 해제
<div class="admin-media__selection-actions flex flex-wrap items-center justify-end gap-2">
<button
v-if="filteredMediaItems.length"
class="admin-media__select-all rounded border border-line bg-white px-3 py-2 text-xs font-semibold text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:aria-pressed="isAllFilteredMediaSelected"
@click="toggleFilteredMediaSelection"
>
{{ isAllFilteredMediaSelected ? '전체 해제' : '전체 선택' }}
</button>
<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-delete font-semibold text-red-600 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="deletingSelected"
@click="deleteSelectedMedia"
>
{{ deletingSelected ? '삭제 중' : '선택 삭제' }}
</button>
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection">
선택 해제
</button>
</div>
</div>
</div>