Files
sori.studio/components/admin/AdminMarkdownMediaPickerModal.vue

363 lines
13 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>