363 lines
13 KiB
Vue
363 lines
13 KiB
Vue
<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>
|
||
|
||
<style scoped>
|
||
.admin-markdown-media-picker__upload-zone--uploading::before {
|
||
position: absolute;
|
||
inset: 0;
|
||
content: '';
|
||
background: linear-gradient(110deg, transparent 0%, rgba(255, 255, 255, 0.72) 45%, transparent 72%);
|
||
transform: translateX(-100%);
|
||
animation: admin-markdown-media-picker-upload-sheen 1.4s ease-in-out infinite;
|
||
}
|
||
|
||
.admin-markdown-media-picker__upload-spinner {
|
||
animation: admin-markdown-media-picker-upload-spin 0.8s linear infinite;
|
||
}
|
||
|
||
.admin-markdown-media-picker__upload-skeleton::before {
|
||
display: block;
|
||
width: 40%;
|
||
height: 100%;
|
||
content: '';
|
||
border-radius: inherit;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.86), transparent);
|
||
animation: admin-markdown-media-picker-upload-skeleton 1.15s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes admin-markdown-media-picker-upload-spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@keyframes admin-markdown-media-picker-upload-sheen {
|
||
to {
|
||
transform: translateX(100%);
|
||
}
|
||
}
|
||
|
||
@keyframes admin-markdown-media-picker-upload-skeleton {
|
||
from {
|
||
transform: translateX(-120%);
|
||
}
|
||
|
||
to {
|
||
transform: translateX(260%);
|
||
}
|
||
}
|
||
</style>
|