대표 이미지와 미디어 화면 개선

This commit is contained in:
2026-05-02 09:45:37 +09:00
parent e1254c6b5f
commit a7fcd7dce5
9 changed files with 298 additions and 63 deletions

View File

@@ -8,11 +8,14 @@ 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()
@@ -46,16 +49,26 @@ const formatFileSize = (size) => {
}
/**
* 미디어 수정 상태 시작
* 미디어 상세 모달 열기
* @param {Object} item - 미디어 항목
* @returns {void}
*/
const startRename = (item) => {
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}
@@ -80,7 +93,7 @@ const renameMedia = async () => {
errorMessage.value = ''
try {
await $fetch('/admin/api/media', {
const renamedItem = await $fetch('/admin/api/media', {
method: 'PUT',
body: {
url: editingUrl.value,
@@ -89,6 +102,7 @@ const renameMedia = async () => {
})
cancelRename()
await refresh()
selectedMediaUrl.value = renamedItem.url
} catch (error) {
errorMessage.value = error?.data?.message || '파일명을 변경하지 못했습니다.'
}
@@ -119,6 +133,7 @@ const deleteMedia = async (item) => {
url: item.url
}
})
closeMediaDetail()
await refresh()
} catch (error) {
errorMessage.value = error?.data?.message || '파일을 삭제하지 못했습니다.'
@@ -143,7 +158,7 @@ const deleteMedia = async (item) => {
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="파일명 또는 경로 검색"
placeholder="파일명, 경로, 사용처 검색"
>
</div>
@@ -151,40 +166,79 @@ const deleteMedia = async (item) => {
{{ errorMessage }}
</p>
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<article v-for="item in filteredMediaItems" :key="item.url" class="admin-media__item border border-line bg-white">
<img class="admin-media__image aspect-[4/3] w-full bg-surface object-cover" :src="item.url" :alt="item.title">
<div class="admin-media__body grid gap-3 p-4 text-sm">
<div v-if="editingUrl === item.url" class="admin-media__rename grid gap-2">
<input
v-model="editingName"
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm"
type="text"
@keydown.enter.prevent="renameMedia"
>
<div class="admin-media__rename-actions flex gap-2">
<button class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white" type="button" @click="renameMedia">
저장
</button>
<button class="admin-media__rename-cancel rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="cancelRename">
취소
</button>
</div>
</div>
<template v-else>
<strong class="admin-media__name break-all text-ink">{{ item.name }}</strong>
<p class="admin-media__path break-all text-xs text-muted">{{ item.url }}</p>
</template>
<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>
<p class="admin-media__meta text-xs text-muted">
{{ item.category }} · {{ formatFileSize(item.size) }}
</p>
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
<strong class="admin-media__usage-title text-ink">
사용 현황 {{ item.usage.length }}
사용 현황 {{ selectedMedia.usage.length }}
</strong>
<ul v-if="item.usage.length" class="admin-media__usage-list mt-2 grid gap-1.5 text-muted">
<li v-for="usage in item.usage" :key="`${item.url}-${usage.type}-${usage.id}-${usage.location}`" class="admin-media__usage-item">
<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"
@@ -200,30 +254,45 @@ const deleteMedia = async (item) => {
사용 중인 곳이 없습니다.
</p>
</div>
<div class="admin-media__actions flex gap-2">
<button
class="admin-media__rename-button rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
type="button"
:disabled="item.usage.length > 0"
@click="startRename(item)"
<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 === item.url || item.usage.length > 0"
@click="deleteMedia(item)"
:disabled="deletingUrl === selectedMedia.url || selectedMedia.usage.length > 0"
@click="deleteMedia(selectedMedia)"
>
{{ deletingUrl === item.url ? '삭제 중' : '삭제' }}
{{ deletingUrl === selectedMedia.url ? '삭제 중' : '삭제' }}
</button>
</div>
</div>
</article>
</aside>
</section>
</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>
</section>
</template>