에디터 미디어 모달 컴포넌트 분리
This commit is contained in:
@@ -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" />
|
||||
|
||||
315
components/admin/AdminMarkdownMediaPickerModal.vue
Normal file
315
components/admin/AdminMarkdownMediaPickerModal.vue
Normal 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>
|
||||
Reference in New Issue
Block a user