미디어 선택 삭제와 모바일 목차 정리 v1.5.14
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user