299 lines
10 KiB
Vue
299 lines
10 KiB
Vue
<script setup>
|
|
definePageMeta({
|
|
layout: 'admin'
|
|
})
|
|
|
|
const searchText = ref('')
|
|
const editingUrl = ref('')
|
|
const editingName = ref('')
|
|
const deletingUrl = ref('')
|
|
const errorMessage = ref('')
|
|
const selectedMediaUrl = ref('')
|
|
|
|
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
|
|
default: () => []
|
|
})
|
|
|
|
const selectedMedia = computed(() => mediaItems.value.find((item) => item.url === selectedMediaUrl.value) || null)
|
|
|
|
const filteredMediaItems = computed(() => {
|
|
const query = searchText.value.trim().toLowerCase()
|
|
|
|
if (!query) {
|
|
return mediaItems.value
|
|
}
|
|
|
|
return mediaItems.value.filter((item) => [
|
|
item.name,
|
|
item.url,
|
|
item.category,
|
|
...item.usage.map((usage) => usage.title)
|
|
].some((value) => value.toLowerCase().includes(query)))
|
|
})
|
|
|
|
/**
|
|
* 파일 크기 표시 문자열 생성
|
|
* @param {number} size - 파일 크기
|
|
* @returns {string} 표시 문자열
|
|
*/
|
|
const formatFileSize = (size) => {
|
|
if (size < 1024) {
|
|
return `${size} B`
|
|
}
|
|
|
|
if (size < 1024 * 1024) {
|
|
return `${(size / 1024).toFixed(1)} KB`
|
|
}
|
|
|
|
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
|
}
|
|
|
|
/**
|
|
* 미디어 상세 모달 열기
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {void}
|
|
*/
|
|
const openMediaDetail = (item) => {
|
|
selectedMediaUrl.value = item.url
|
|
editingUrl.value = item.url
|
|
editingName.value = item.title
|
|
errorMessage.value = ''
|
|
}
|
|
|
|
/**
|
|
* 미디어 상세 모달 닫기
|
|
* @returns {void}
|
|
*/
|
|
const closeMediaDetail = () => {
|
|
selectedMediaUrl.value = ''
|
|
cancelRename()
|
|
}
|
|
|
|
/**
|
|
* 미디어 파일명 변경 취소
|
|
* @returns {void}
|
|
*/
|
|
const cancelRename = () => {
|
|
editingUrl.value = ''
|
|
editingName.value = ''
|
|
}
|
|
|
|
/**
|
|
* 미디어 파일명 변경
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const renameMedia = async () => {
|
|
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
|
|
|
|
if (editingItem?.usage.length) {
|
|
errorMessage.value = '사용 중인 미디어는 파일명을 변경할 수 없습니다.'
|
|
return
|
|
}
|
|
|
|
errorMessage.value = ''
|
|
|
|
try {
|
|
const renamedItem = await $fetch('/admin/api/media', {
|
|
method: 'PUT',
|
|
body: {
|
|
url: editingUrl.value,
|
|
name: editingName.value
|
|
}
|
|
})
|
|
cancelRename()
|
|
await refresh()
|
|
selectedMediaUrl.value = renamedItem.url
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '파일명을 변경하지 못했습니다.'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미디어 삭제
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const deleteMedia = async (item) => {
|
|
if (item.usage.length) {
|
|
errorMessage.value = '사용 중인 미디어는 삭제할 수 없습니다.'
|
|
return
|
|
}
|
|
|
|
if (!confirm(`"${item.name}" 파일을 삭제할까요?`)) {
|
|
return
|
|
}
|
|
|
|
deletingUrl.value = item.url
|
|
errorMessage.value = ''
|
|
|
|
try {
|
|
await $fetch('/admin/api/media', {
|
|
method: 'DELETE',
|
|
body: {
|
|
url: item.url
|
|
}
|
|
})
|
|
closeMediaDetail()
|
|
await refresh()
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '파일을 삭제하지 못했습니다.'
|
|
} finally {
|
|
deletingUrl.value = ''
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="admin-media bg-paper p-6">
|
|
<div class="admin-media__header flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<p class="admin-media__eyebrow text-xs font-semibold uppercase text-muted">
|
|
Media
|
|
</p>
|
|
<h1 class="admin-media__title mt-2 text-3xl font-semibold">
|
|
미디어
|
|
</h1>
|
|
</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"
|
|
>
|
|
{{ 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>
|
|
|
|
<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
|
|
v-if="selectedMedia"
|
|
class="admin-media__modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
@click.self="closeMediaDetail"
|
|
>
|
|
<section class="admin-media__modal-panel grid max-h-[86vh] w-full max-w-5xl overflow-hidden bg-white text-ink shadow-xl lg:grid-cols-[minmax(0,1fr)_22rem]">
|
|
<div class="admin-media__preview grid min-h-[20rem] place-items-center bg-[#f5f5f2] p-5">
|
|
<img class="admin-media__preview-image max-h-[72vh] max-w-full object-contain" :src="selectedMedia.url" :alt="selectedMedia.title">
|
|
</div>
|
|
|
|
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
|
|
<div class="admin-media__detail-header flex items-start justify-between gap-3">
|
|
<div>
|
|
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
|
|
Attachment
|
|
</p>
|
|
<h2 class="admin-media__detail-title mt-1 break-all text-xl font-semibold">
|
|
{{ selectedMedia.name }}
|
|
</h2>
|
|
</div>
|
|
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
|
|
닫기
|
|
</button>
|
|
</div>
|
|
|
|
<dl class="admin-media__info grid gap-3 text-sm">
|
|
<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 break-all">{{ selectedMedia.url }}</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">{{ 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>
|
|
</div>
|
|
</dl>
|
|
|
|
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
|
|
<strong class="admin-media__usage-title text-ink">
|
|
사용 현황 {{ selectedMedia.usage.length }}곳
|
|
</strong>
|
|
<ul v-if="selectedMedia.usage.length" class="admin-media__usage-list mt-2 grid gap-1.5 text-muted">
|
|
<li v-for="usage in selectedMedia.usage" :key="`${selectedMedia.url}-${usage.type}-${usage.id}-${usage.location}`" class="admin-media__usage-item">
|
|
<NuxtLink
|
|
v-if="usage.adminUrl"
|
|
class="admin-media__usage-link font-semibold text-ink hover:opacity-70"
|
|
:to="usage.adminUrl"
|
|
>
|
|
{{ usage.title }}
|
|
</NuxtLink>
|
|
<span v-else class="admin-media__usage-name font-semibold text-ink">{{ usage.title }}</span>
|
|
<span class="admin-media__usage-meta"> · {{ usage.typeLabel }} · {{ usage.label }}</span>
|
|
</li>
|
|
</ul>
|
|
<p v-else class="admin-media__usage-empty mt-2 text-muted">
|
|
사용 중인 곳이 없습니다.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="admin-media__rename grid gap-2">
|
|
<label class="admin-media__rename-label text-xs font-semibold text-muted" for="media-name">
|
|
파일명
|
|
</label>
|
|
<input
|
|
id="media-name"
|
|
v-model="editingName"
|
|
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50"
|
|
type="text"
|
|
:disabled="selectedMedia.usage.length > 0"
|
|
:placeholder="selectedMedia.title"
|
|
@keydown.enter.prevent="renameMedia"
|
|
>
|
|
<p v-if="selectedMedia.usage.length" class="admin-media__locked text-xs text-muted">
|
|
사용 중인 미디어는 파일명 변경과 삭제가 잠겨 있습니다.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="admin-media__actions flex flex-wrap gap-2">
|
|
<button
|
|
class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50"
|
|
type="button"
|
|
:disabled="selectedMedia.usage.length > 0 || !editingName"
|
|
@click="renameMedia"
|
|
>
|
|
파일명 저장
|
|
</button>
|
|
<button
|
|
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
|
type="button"
|
|
:disabled="deletingUrl === selectedMedia.url || selectedMedia.usage.length > 0"
|
|
@click="deleteMedia(selectedMedia)"
|
|
>
|
|
{{ deletingUrl === selectedMedia.url ? '삭제 중' : '삭제' }}
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
</template>
|