에디터 미디어 모달 컴포넌트 분리

This commit is contained in:
2026-07-24 18:53:56 +09:00
parent 48982cfa52
commit 84cb3ad9e7
8 changed files with 378 additions and 210 deletions

View File

@@ -33,12 +33,11 @@ import {
getMediaPickerAcceptForTarget,
getMediaPickerKindForTarget,
getGallerySelectionOrder as getGallerySelectionOrderFromSelection,
getMediaItemKind,
getMediaPickerPreviewUrl,
getMediaPickerTitleForTarget,
isGalleryMediaPickerTarget,
isMediaItemSelectableForTarget,
isUploadFileAllowedForMediaTarget,
normalizeMediaPickerPage,
normalizeMediaPickerResponse,
resolveSelectedMediaItems,
resolveUploadFetchErrorMessage,
@@ -3076,7 +3075,7 @@ const getGallerySelectionOrder = (url) => {
* @returns {Promise<void>}
*/
const setMediaPickerPage = async (page) => {
mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value)
mediaPickerPage.value = normalizeMediaPickerPage(page, mediaPickerPageCount.value)
await fetchMediaItems()
}
@@ -3627,206 +3626,36 @@ const handleKeydown = (event) => {
</div>
</Teleport>
<div v-if="isMediaPickerOpen" class="admin-markdown-editor__media-modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-4 py-6" @click.self="closeMediaPicker">
<div class="admin-markdown-editor__media-panel flex h-[min(86vh,760px)] min-h-[620px] w-full max-w-5xl flex-col overflow-hidden rounded bg-white shadow-2xl">
<header class="admin-markdown-editor__media-header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4">
<div>
<h2 class="admin-markdown-editor__media-title text-lg font-bold text-black">
{{ mediaPickerTitle }}
</h2>
</div>
<button
class="admin-markdown-editor__media-close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="closeMediaPicker"
>
닫기
</button>
</header>
<div class="admin-markdown-editor__media-tabs flex items-center gap-3 border-b border-[#e3e6e8] px-5">
<div class="flex min-w-0 flex-1 items-center">
<button
class="admin-markdown-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'upload'"
>
업로드
</button>
<button
class="admin-markdown-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'library'"
>
미디어 라이브러리
</button>
</div>
<label v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-search mb-px flex w-full max-w-xs shrink-0 items-center py-2">
<span class="sr-only">파일명 검색</span>
<input
:value="mediaSearchInput"
class="admin-markdown-editor__media-search-input w-full rounded border border-[#d7dde2] bg-white px-3 py-1.5 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]/20"
type="search"
placeholder="파일명 검색"
autocomplete="off"
@input="onMediaSearchInput"
@compositionupdate="onMediaSearchInput"
@compositionend="onMediaSearchCompositionEnd"
>
</label>
</div>
<div class="admin-markdown-editor__media-body min-h-0 flex-1 overflow-y-auto p-5">
<template v-if="activeMediaPickerTab === 'library'">
<div v-if="isLoadingMedia" class="admin-markdown-editor__media-loading py-12 text-center text-sm text-[#8e9cac]">
불러오는 중
</div>
<div v-else-if="filteredMediaItems.length" class="admin-markdown-editor__media-grid grid grid-cols-2 gap-3 md:grid-cols-4">
<button
v-for="item in filteredMediaItems"
:key="item.url"
class="admin-markdown-editor__media-item group relative overflow-hidden rounded border bg-white text-left transition"
:class="selectedMediaUrls.includes(item.url) ? 'border-[3px] border-[#ff7a00] ring-2 ring-[#ff7a00]/40' : 'border border-[#e3e6e8] hover:border-[#8e9cac]'"
type="button"
@click="toggleMediaSelection(item)"
>
<span
v-if="getGallerySelectionOrder(item.url)"
class="admin-markdown-editor__media-selection-order absolute right-2 top-2 z-10 grid size-7 place-items-center rounded-full bg-[#ff7a00] text-xs font-bold text-white shadow-sm ring-2 ring-white"
>
{{ getGallerySelectionOrder(item.url) }}
</span>
<img
v-if="getMediaItemKind(item) === 'image'"
class="admin-markdown-editor__media-thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover"
:src="getMediaPickerPreviewUrl(item)"
:alt="item.name || ''"
loading="lazy"
decoding="async"
@error="onMediaPickerImageError($event, item)"
>
<span
v-else
class="admin-markdown-editor__media-thumb flex aspect-[4/3] w-full items-center justify-center bg-[#f6f7f8] text-xs font-bold uppercase tracking-[0.18em] text-[#6b7280]"
>
{{ getMediaItemKind(item) }}
</span>
<span class="admin-markdown-editor__media-name block truncate px-3 py-2 text-xs font-semibold text-[#394047]">
{{ item.name || item.url }}
</span>
</button>
</div>
<div v-else class="admin-markdown-editor__media-empty py-12 text-center text-sm text-[#8e9cac]">
{{ mediaPickerEmptyMessage }}
</div>
</template>
<div
v-else
class="admin-markdown-editor__media-upload-zone relative grid h-full min-h-[420px] place-items-center overflow-hidden rounded border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
:class="isUploading ? 'admin-markdown-editor__media-upload-zone--uploading select-none border-[#8e9cac] bg-[#f3f5f7]' : ''"
@dragover.prevent
@drop.prevent="handleMediaModalDrop"
>
<div class="admin-markdown-editor__media-upload-inner grid gap-3 px-6">
<p class="admin-markdown-editor__media-upload-title text-lg font-semibold text-[#15171a]">
{{ isUploading ? '업로드 중입니다' : '파일을 끌어 업로드' }}
</p>
<p class="admin-markdown-editor__media-upload-or text-sm text-[#6b7280]">
{{ isUploading ? '잠시만 기다려 주세요.' : '또는' }}
</p>
<label
class="admin-markdown-editor__media-upload-button mx-auto inline-flex h-10 items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50"
:class="isUploading ? 'cursor-not-allowed border-[#c8ced3] text-[#8e9cac] hover:bg-transparent' : 'cursor-pointer'"
>
{{ isUploading ? '업로드 중' : '파일 선택' }}
<input
class="sr-only"
type="file"
:accept="mediaPickerAccept"
:multiple="isGalleryMediaPicker"
:disabled="isUploading"
@change="uploadFromMediaModal($event.target.files); $event.target.value = ''"
>
</label>
<p class="admin-markdown-editor__media-upload-hint text-xs text-[#8e9cac]">
{{ mediaPickerUploadHint }}
</p>
</div>
<div
v-if="isUploading"
class="admin-markdown-editor__media-upload-overlay pointer-events-none absolute inset-0 grid place-items-center bg-white/72 backdrop-blur-[1px]"
aria-live="polite"
>
<div class="admin-markdown-editor__media-upload-loading grid w-full max-w-sm gap-4 px-8 text-center">
<span class="admin-markdown-editor__media-upload-spinner mx-auto size-8 rounded-full border-2 border-[#d7dde2] border-t-[#15171a]" aria-hidden="true" />
<strong class="text-sm font-semibold text-[#15171a]">업로드 중입니다</strong>
<span class="admin-markdown-editor__media-upload-skeleton h-2 overflow-hidden rounded-full bg-[#e3e6e8]" aria-hidden="true" />
</div>
</div>
</div>
</div>
<footer v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-footer flex flex-wrap items-center justify-between gap-3 border-t border-[#e3e6e8] px-5 py-4">
<div class="admin-markdown-editor__media-pagination flex flex-wrap items-center gap-2 text-xs font-semibold text-[#6b7280]">
<span>{{ mediaPickerRangeLabel }}</span>
<nav v-if="mediaPickerPageCount > 1" class="flex flex-wrap items-center gap-1" aria-label="미디어 선택 페이지">
<button
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoadingMedia || mediaPickerPage <= 1"
aria-label="이전 페이지"
@click="setMediaPickerPage(mediaPickerPage - 1)"
>
‹
</button>
<template v-for="item in mediaPickerPageItems" :key="item">
<span v-if="typeof item === 'string'" class="px-1 text-[#8e9cac]">...</span>
<button
v-else
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full text-xs font-semibold transition"
:class="item === mediaPickerPage ? 'bg-[#15171a] text-white' : 'text-[#8e9cac] hover:bg-[#f1f3f5] hover:text-[#394047]'"
type="button"
:disabled="isLoadingMedia"
@click="setMediaPickerPage(item)"
>
{{ item }}
</button>
</template>
<button
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoadingMedia || mediaPickerPage >= mediaPickerPageCount"
aria-label="다음 페이지"
@click="setMediaPickerPage(mediaPickerPage + 1)"
>
›
</button>
</nav>
</div>
<div class="admin-markdown-editor__media-actions flex items-center justify-end gap-2">
<button
class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="closeMediaPicker"
>
취소
</button>
<button
class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="mediaPickerApplyState.disabled"
@click="applyMediaSelection"
>
{{ mediaPickerApplyState.label }}
</button>
</div>
</footer>
</div>
</div>
<AdminMarkdownMediaPickerModal
v-if="isMediaPickerOpen"
:title="mediaPickerTitle"
:active-tab="activeMediaPickerTab"
:search-input="mediaSearchInput"
:media-items="filteredMediaItems"
:selected-urls="selectedMediaUrls"
:is-loading="isLoadingMedia"
:is-uploading="isUploading"
:accept="mediaPickerAccept"
:multiple="isGalleryMediaPicker"
:upload-hint="mediaPickerUploadHint"
:empty-message="mediaPickerEmptyMessage"
:range-label="mediaPickerRangeLabel"
:page="mediaPickerPage"
:page-count="mediaPickerPageCount"
:page-items="mediaPickerPageItems"
:apply-state="mediaPickerApplyState"
:get-selection-order="getGallerySelectionOrder"
@apply="applyMediaSelection"
@close="closeMediaPicker"
@drop="handleMediaModalDrop"
@image-error="onMediaPickerImageError($event.event, $event.item)"
@page="setMediaPickerPage"
@search-composition-end="onMediaSearchCompositionEnd"
@search-input="onMediaSearchInput"
@tab="activeMediaPickerTab = $event"
@toggle="toggleMediaSelection"
@upload="uploadFromMediaModal"
/>
</div>
<AdminToast :toast="toast" />

View File

@@ -0,0 +1,315 @@
<script setup>
import {
getMediaItemKind,
getMediaPickerPreviewUrl
} from '../../lib/markdown-editor-media.js'
const props = defineProps({
title: {
type: String,
required: true
},
activeTab: {
type: String,
required: true
},
searchInput: {
type: String,
default: ''
},
mediaItems: {
type: Array,
default: () => []
},
selectedUrls: {
type: Array,
default: () => []
},
isLoading: {
type: Boolean,
default: false
},
isUploading: {
type: Boolean,
default: false
},
accept: {
type: String,
default: ''
},
multiple: {
type: Boolean,
default: false
},
uploadHint: {
type: String,
default: ''
},
emptyMessage: {
type: String,
default: ''
},
rangeLabel: {
type: String,
default: ''
},
page: {
type: Number,
default: 1
},
pageCount: {
type: Number,
default: 1
},
pageItems: {
type: Array,
default: () => []
},
applyState: {
type: Object,
default: () => ({
disabled: true,
label: '삽입'
})
},
getSelectionOrder: {
type: Function,
required: true
}
})
const emit = defineEmits([
'apply',
'close',
'drop',
'image-error',
'page',
'search-composition-end',
'search-input',
'tab',
'toggle',
'upload'
])
/**
* 탭을 변경한다.
* @param {string} tab - 변경할 탭
* @returns {void}
*/
const selectTab = (tab) => {
emit('tab', tab)
}
/**
* 파일 입력 변경을 부모로 전달한다.
* @param {Event} event - 파일 입력 이벤트
* @returns {void}
*/
const handleFileChange = (event) => {
emit('upload', event.target.files)
event.target.value = ''
}
</script>
<template>
<div class="admin-markdown-media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-4 py-6" @click.self="emit('close')">
<div class="admin-markdown-media-picker__panel flex h-[min(86vh,760px)] min-h-[620px] w-full max-w-5xl flex-col overflow-hidden rounded bg-white shadow-2xl">
<header class="admin-markdown-media-picker__header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4">
<div>
<h2 class="admin-markdown-media-picker__title text-lg font-bold text-black">
{{ title }}
</h2>
</div>
<button
class="admin-markdown-media-picker__close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="emit('close')"
>
닫기
</button>
</header>
<div class="admin-markdown-media-picker__tabs flex items-center gap-3 border-b border-[#e3e6e8] px-5">
<div class="flex min-w-0 flex-1 items-center">
<button
class="admin-markdown-media-picker__tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="selectTab('upload')"
>
업로드
</button>
<button
class="admin-markdown-media-picker__tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="selectTab('library')"
>
미디어 라이브러리
</button>
</div>
<label v-if="activeTab === 'library'" class="admin-markdown-media-picker__search mb-px flex w-full max-w-xs shrink-0 items-center py-2">
<span class="sr-only">파일명 검색</span>
<input
:value="searchInput"
class="admin-markdown-media-picker__search-input w-full rounded border border-[#d7dde2] bg-white px-3 py-1.5 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]/20"
type="search"
placeholder="파일명 검색"
autocomplete="off"
@input="emit('search-input', $event)"
@compositionupdate="emit('search-input', $event)"
@compositionend="emit('search-composition-end', $event)"
>
</label>
</div>
<div class="admin-markdown-media-picker__body min-h-0 flex-1 overflow-y-auto p-5">
<template v-if="activeTab === 'library'">
<div v-if="isLoading" class="admin-markdown-media-picker__loading py-12 text-center text-sm text-[#8e9cac]">
불러오는 중
</div>
<div v-else-if="mediaItems.length" class="admin-markdown-media-picker__grid grid grid-cols-2 gap-3 md:grid-cols-4">
<button
v-for="item in mediaItems"
:key="item.url"
class="admin-markdown-media-picker__item group relative overflow-hidden rounded border bg-white text-left transition"
:class="selectedUrls.includes(item.url) ? 'border-[3px] border-[#ff7a00] ring-2 ring-[#ff7a00]/40' : 'border border-[#e3e6e8] hover:border-[#8e9cac]'"
type="button"
@click="emit('toggle', item)"
>
<span
v-if="props.getSelectionOrder(item.url)"
class="admin-markdown-media-picker__selection-order absolute right-2 top-2 z-10 grid size-7 place-items-center rounded-full bg-[#ff7a00] text-xs font-bold text-white shadow-sm ring-2 ring-white"
>
{{ props.getSelectionOrder(item.url) }}
</span>
<img
v-if="getMediaItemKind(item) === 'image'"
class="admin-markdown-media-picker__thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover"
:src="getMediaPickerPreviewUrl(item)"
:alt="item.name || ''"
loading="lazy"
decoding="async"
@error="emit('image-error', { event: $event, item })"
>
<span
v-else
class="admin-markdown-media-picker__thumb flex aspect-[4/3] w-full items-center justify-center bg-[#f6f7f8] text-xs font-bold uppercase tracking-[0.18em] text-[#6b7280]"
>
{{ getMediaItemKind(item) }}
</span>
<span class="admin-markdown-media-picker__name block truncate px-3 py-2 text-xs font-semibold text-[#394047]">
{{ item.name || item.url }}
</span>
</button>
</div>
<div v-else class="admin-markdown-media-picker__empty py-12 text-center text-sm text-[#8e9cac]">
{{ emptyMessage }}
</div>
</template>
<div
v-else
class="admin-markdown-media-picker__upload-zone relative grid h-full min-h-[420px] place-items-center overflow-hidden rounded border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
:class="isUploading ? 'admin-markdown-media-picker__upload-zone--uploading select-none border-[#8e9cac] bg-[#f3f5f7]' : ''"
@dragover.prevent
@drop.prevent="emit('drop', $event)"
>
<div class="admin-markdown-media-picker__upload-inner grid gap-3 px-6">
<p class="admin-markdown-media-picker__upload-title text-lg font-semibold text-[#15171a]">
{{ isUploading ? '업로드 중입니다' : '파일을 끌어 업로드' }}
</p>
<p class="admin-markdown-media-picker__upload-or text-sm text-[#6b7280]">
{{ isUploading ? '잠시만 기다려 주세요.' : '또는' }}
</p>
<label
class="admin-markdown-media-picker__upload-button mx-auto inline-flex h-10 items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50"
:class="isUploading ? 'cursor-not-allowed border-[#c8ced3] text-[#8e9cac] hover:bg-transparent' : 'cursor-pointer'"
>
{{ isUploading ? '업로드 중' : '파일 선택' }}
<input
class="sr-only"
type="file"
:accept="accept"
:multiple="multiple"
:disabled="isUploading"
@change="handleFileChange"
>
</label>
<p class="admin-markdown-media-picker__upload-hint text-xs text-[#8e9cac]">
{{ uploadHint }}
</p>
</div>
<div
v-if="isUploading"
class="admin-markdown-media-picker__upload-overlay pointer-events-none absolute inset-0 grid place-items-center bg-white/72 backdrop-blur-[1px]"
aria-live="polite"
>
<div class="admin-markdown-media-picker__upload-loading grid w-full max-w-sm gap-4 px-8 text-center">
<span class="admin-markdown-media-picker__upload-spinner mx-auto size-8 rounded-full border-2 border-[#d7dde2] border-t-[#15171a]" aria-hidden="true" />
<strong class="text-sm font-semibold text-[#15171a]">업로드 중입니다</strong>
<span class="admin-markdown-media-picker__upload-skeleton h-2 overflow-hidden rounded-full bg-[#e3e6e8]" aria-hidden="true" />
</div>
</div>
</div>
</div>
<footer v-if="activeTab === 'library'" class="admin-markdown-media-picker__footer flex flex-wrap items-center justify-between gap-3 border-t border-[#e3e6e8] px-5 py-4">
<div class="admin-markdown-media-picker__pagination flex flex-wrap items-center gap-2 text-xs font-semibold text-[#6b7280]">
<span>{{ rangeLabel }}</span>
<nav v-if="pageCount > 1" class="flex flex-wrap items-center gap-1" aria-label="미디어 선택 페이지">
<button
class="admin-markdown-media-picker__page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoading || page <= 1"
aria-label="이전 페이지"
@click="emit('page', page - 1)"
>
‹
</button>
<template v-for="item in pageItems" :key="item">
<span v-if="typeof item === 'string'" class="px-1 text-[#8e9cac]">...</span>
<button
v-else
class="admin-markdown-media-picker__page-button grid size-8 place-items-center rounded-full text-xs font-semibold transition"
:class="item === page ? 'bg-[#15171a] text-white' : 'text-[#8e9cac] hover:bg-[#f1f3f5] hover:text-[#394047]'"
type="button"
:disabled="isLoading"
@click="emit('page', item)"
>
{{ item }}
</button>
</template>
<button
class="admin-markdown-media-picker__page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoading || page >= pageCount"
aria-label="다음 페이지"
@click="emit('page', page + 1)"
>
›
</button>
</nav>
</div>
<div class="admin-markdown-media-picker__actions flex items-center justify-end gap-2">
<button
class="admin-markdown-media-picker__cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="emit('close')"
>
취소
</button>
<button
class="admin-markdown-media-picker__apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="applyState.disabled"
@click="emit('apply')"
>
{{ applyState.label }}
</button>
</div>
</footer>
</div>
</div>
</template>