관리자 미디어 선택과 초안 자동 저장 개선
This commit is contained in:
@@ -3890,7 +3890,7 @@ const handleKeydown = (event) => {
|
||||
</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 max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded bg-white shadow-2xl">
|
||||
<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">
|
||||
@@ -3944,7 +3944,7 @@ const handleKeydown = (event) => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-markdown-editor__media-body flex-1 overflow-y-auto p-5">
|
||||
<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]">
|
||||
불러오는 중
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
} from '../../lib/admin-post-title.js'
|
||||
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
||||
import {
|
||||
getPostBodyPreviewThumbnailUrl,
|
||||
isPageCoverThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl
|
||||
isPostCardThumbnailUrl,
|
||||
withGeneratedImageCacheVersion
|
||||
} from '../../lib/post-card-thumbnail-url.js'
|
||||
import { DEFAULT_POST_TAG_LIMIT, normalizePostTagLimit } from '../../lib/post-tag-limit.js'
|
||||
|
||||
@@ -79,7 +81,22 @@ const isTagInputComposing = ref(false)
|
||||
const isTitleInputComposing = ref(false)
|
||||
const activeMediaPickerTab = ref('upload')
|
||||
const selectedMediaPickerUrl = ref('')
|
||||
const mediaSearchQuery = ref('')
|
||||
const {
|
||||
displayValue: mediaSearchInput,
|
||||
onInput: onMediaSearchInput,
|
||||
onCompositionEnd: onMediaSearchCompositionEnd
|
||||
} = useImeSearchInput(mediaSearchQuery, { debounce: 250 })
|
||||
const mediaPickerPage = ref(1)
|
||||
const mediaPickerPageSize = 50
|
||||
const mediaPickerPagination = ref({
|
||||
page: 1,
|
||||
pageSize: mediaPickerPageSize,
|
||||
total: 0,
|
||||
pageCount: 1
|
||||
})
|
||||
const savedPostSnapshot = ref('')
|
||||
const failedDraftAutosaveSnapshot = ref('')
|
||||
const isPublishModalOpen = ref(false)
|
||||
const isUnpublishModalOpen = ref(false)
|
||||
const publishStatus = ref('draft')
|
||||
@@ -110,14 +127,6 @@ const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl)
|
||||
|| isPostBodyThumbnailUrl(imageUrl)
|
||||
|| isPageCoverThumbnailUrl(imageUrl)
|
||||
|
||||
/**
|
||||
* 대표 이미지로 선택 가능한 원본 이미지 목록
|
||||
* @returns {Array<Object>} 자동 생성 이미지를 제외한 이미지 목록
|
||||
*/
|
||||
const featuredImageMediaItems = computed(() => mediaItems.value.filter((item) => (
|
||||
item?.kind === 'image' && !isGeneratedThumbnailUrl(item.url)
|
||||
)))
|
||||
|
||||
/**
|
||||
* ISO 날짜를 datetime-local 입력값으로 변환
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
@@ -734,6 +743,11 @@ const hasUnsavedPostChanges = computed(() => serializePostPayload() !== savedPos
|
||||
|
||||
const serializedPostFingerprint = computed(() => serializePostPayload())
|
||||
|
||||
const isFailedDraftAutosavePayload = computed(() => Boolean(
|
||||
failedDraftAutosaveSnapshot.value
|
||||
&& failedDraftAutosaveSnapshot.value === serializedPostFingerprint.value
|
||||
))
|
||||
|
||||
/**
|
||||
* 초안 자동 저장을 서버에 보낼 수 있는지(슬러그 유효, 제목은 비어 있으면 서버에서 `(제목 없음)`으로 보정)
|
||||
* @returns {boolean}
|
||||
@@ -837,7 +851,25 @@ const fetchMediaItems = async () => {
|
||||
isLoadingMedia.value = true
|
||||
|
||||
try {
|
||||
mediaItems.value = await $fetch('/admin/api/media')
|
||||
const response = await $fetch('/admin/api/media', {
|
||||
query: {
|
||||
paged: '1',
|
||||
tab: 'library',
|
||||
page: mediaPickerPage.value,
|
||||
pageSize: mediaPickerPageSize,
|
||||
kind: 'image',
|
||||
search: mediaSearchQuery.value.trim() || undefined
|
||||
}
|
||||
})
|
||||
|
||||
mediaItems.value = response?.items || []
|
||||
mediaPickerPagination.value = response?.pagination || {
|
||||
page: 1,
|
||||
pageSize: mediaPickerPageSize,
|
||||
total: mediaItems.value.length,
|
||||
pageCount: 1
|
||||
}
|
||||
mediaPickerPage.value = mediaPickerPagination.value.page || 1
|
||||
} finally {
|
||||
isLoadingMedia.value = false
|
||||
}
|
||||
@@ -850,9 +882,16 @@ const fetchMediaItems = async () => {
|
||||
const openMediaPicker = async (target = 'featuredImage') => {
|
||||
mediaPickerTarget.value = target
|
||||
activeMediaPickerTab.value = 'upload'
|
||||
mediaSearchQuery.value = ''
|
||||
mediaPickerPage.value = 1
|
||||
mediaPickerPagination.value = {
|
||||
page: 1,
|
||||
pageSize: mediaPickerPageSize,
|
||||
total: 0,
|
||||
pageCount: 1
|
||||
}
|
||||
selectedMediaPickerUrl.value = isGeneratedThumbnailUrl(form[target]) ? '' : form[target] || ''
|
||||
isMediaPickerOpen.value = true
|
||||
await fetchMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -860,9 +899,129 @@ const openMediaPicker = async (target = 'featuredImage') => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeMediaPicker = () => {
|
||||
if (isUploadingFeaturedImage.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isMediaPickerOpen.value = false
|
||||
mediaItems.value = []
|
||||
mediaSearchQuery.value = ''
|
||||
mediaPickerPage.value = 1
|
||||
mediaPickerPagination.value = {
|
||||
page: 1,
|
||||
pageSize: mediaPickerPageSize,
|
||||
total: 0,
|
||||
pageCount: 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지로 선택 가능한 원본 이미지 목록
|
||||
* @returns {Array<Object>} 자동 생성 이미지를 제외한 이미지 목록
|
||||
*/
|
||||
const featuredImageMediaItems = computed(() => mediaItems.value.filter((item) => (
|
||||
item?.kind === 'image' && !isGeneratedThumbnailUrl(item.url)
|
||||
)))
|
||||
|
||||
const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1))
|
||||
|
||||
const mediaPickerRangeLabel = computed(() => {
|
||||
const total = Number(mediaPickerPagination.value.total) || 0
|
||||
|
||||
if (total <= 0) {
|
||||
return '0개'
|
||||
}
|
||||
|
||||
const page = Math.min(Math.max(Number(mediaPickerPagination.value.page) || mediaPickerPage.value, 1), mediaPickerPageCount.value)
|
||||
const pageSize = Number(mediaPickerPagination.value.pageSize) || mediaPickerPageSize
|
||||
const start = ((page - 1) * pageSize) + 1
|
||||
const end = Math.min(start + pageSize - 1, total)
|
||||
|
||||
return `${total}개 중 ${start}-${end}개`
|
||||
})
|
||||
|
||||
const mediaPickerPageItems = computed(() => {
|
||||
const total = mediaPickerPageCount.value
|
||||
const current = Math.min(Math.max(Number(mediaPickerPage.value) || 1, 1), total)
|
||||
const pages = new Set([1, total, current])
|
||||
|
||||
for (let page = current - 2; page <= current + 2; page += 1) {
|
||||
pages.add(page)
|
||||
}
|
||||
|
||||
if (current <= 3) {
|
||||
pages.add(2)
|
||||
pages.add(3)
|
||||
pages.add(4)
|
||||
}
|
||||
|
||||
if (current >= total - 2) {
|
||||
pages.add(total - 3)
|
||||
pages.add(total - 2)
|
||||
pages.add(total - 1)
|
||||
}
|
||||
|
||||
const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right)
|
||||
return orderedPages.reduce((items, page, index) => {
|
||||
const previousPage = orderedPages[index - 1]
|
||||
|
||||
if (previousPage && page - previousPage > 1) {
|
||||
items.push(`ellipsis-${previousPage}-${page}`)
|
||||
}
|
||||
|
||||
items.push(page)
|
||||
return items
|
||||
}, [])
|
||||
})
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 모달 페이지를 변경한다.
|
||||
* @param {number} page - 이동할 페이지
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const setMediaPickerPage = async (page) => {
|
||||
mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value)
|
||||
await fetchMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다.
|
||||
* 선택 값은 원본 URL을 유지하고, 화면 표시만 파생 이미지를 우선 사용한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {string} 미리보기 URL
|
||||
*/
|
||||
const getFeaturedMediaPreviewUrl = (item) => {
|
||||
const url = String(item?.url || '')
|
||||
|
||||
if (item?.bodyThumbnailStatus?.hasThumbnail) {
|
||||
return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(url), `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
}
|
||||
|
||||
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
|
||||
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
watch(mediaSearchQuery, async () => {
|
||||
if (!isMediaPickerOpen.value || activeMediaPickerTab.value !== 'library') {
|
||||
return
|
||||
}
|
||||
|
||||
mediaPickerPage.value = 1
|
||||
await fetchMediaItems()
|
||||
})
|
||||
|
||||
watch(activeMediaPickerTab, async (tab) => {
|
||||
if (!isMediaPickerOpen.value || tab !== 'library') {
|
||||
return
|
||||
}
|
||||
|
||||
mediaPickerPage.value = 1
|
||||
await fetchMediaItems()
|
||||
})
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 상태 변경
|
||||
* @param {Object} item - 미디어 항목
|
||||
@@ -1567,6 +1726,7 @@ const unpublishModalKind = computed(() => (
|
||||
*/
|
||||
const markSaved = (savedPayload = null) => {
|
||||
savedPostSnapshot.value = serializePostPayload(savedPayload)
|
||||
failedDraftAutosaveSnapshot.value = ''
|
||||
const publishedAt = resolvePayloadPublishedAt()
|
||||
|
||||
if (publishedAt) {
|
||||
@@ -1574,6 +1734,15 @@ const markSaved = (savedPayload = null) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패한 초안 자동 저장 입력값을 기록해 같은 요청의 반복 재시도를 막는다.
|
||||
* @param {Object|null} [failedPayload] - 실패한 자동 저장 입력값
|
||||
* @returns {void}
|
||||
*/
|
||||
const markAutosaveFailed = (failedPayload = null) => {
|
||||
failedDraftAutosaveSnapshot.value = serializePostPayload(failedPayload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바운스 타이머를 취소하고, 즉시 서버로 보낼 초안 페이로드가 있으면 반환한다(라우트 이탈 플러시용)
|
||||
* @returns {Object|null} POST/PUT 본문 또는 null
|
||||
@@ -1596,6 +1765,9 @@ const takePendingAutosavePayload = () => {
|
||||
if (!canPersistDraftPayload.value) {
|
||||
return null
|
||||
}
|
||||
if (isFailedDraftAutosavePayload.value) {
|
||||
return null
|
||||
}
|
||||
return createPostPayload()
|
||||
}
|
||||
|
||||
@@ -1631,12 +1803,15 @@ watch(serializedPostFingerprint, () => {
|
||||
if (!canPersistDraftPayload.value) {
|
||||
return
|
||||
}
|
||||
if (isFailedDraftAutosavePayload.value) {
|
||||
return
|
||||
}
|
||||
window.clearTimeout(draftPersistTimer)
|
||||
draftPersistTimer = window.setTimeout(() => {
|
||||
if (props.saving || props.autoSaving) {
|
||||
return
|
||||
}
|
||||
if (!shouldServerAutosaveDraft.value || !hasUnsavedPostChanges.value || !canPersistDraftPayload.value) {
|
||||
if (!shouldServerAutosaveDraft.value || !hasUnsavedPostChanges.value || !canPersistDraftPayload.value || isFailedDraftAutosavePayload.value) {
|
||||
return
|
||||
}
|
||||
emit('autosave', createPostPayload())
|
||||
@@ -1650,13 +1825,16 @@ watch(() => [props.saving, props.autoSaving], ([saving, autoSaving]) => {
|
||||
if (!shouldServerAutosaveDraft.value || !hasUnsavedPostChanges.value || !canPersistDraftPayload.value) {
|
||||
return
|
||||
}
|
||||
if (isFailedDraftAutosavePayload.value) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearTimeout(draftPersistTimer)
|
||||
draftPersistTimer = window.setTimeout(() => {
|
||||
if (props.saving || props.autoSaving) {
|
||||
return
|
||||
}
|
||||
if (!shouldServerAutosaveDraft.value || !hasUnsavedPostChanges.value || !canPersistDraftPayload.value) {
|
||||
if (!shouldServerAutosaveDraft.value || !hasUnsavedPostChanges.value || !canPersistDraftPayload.value || isFailedDraftAutosavePayload.value) {
|
||||
return
|
||||
}
|
||||
emit('autosave', createPostPayload())
|
||||
@@ -1684,6 +1862,7 @@ onBeforeUnmount(() => {
|
||||
defineExpose({
|
||||
clearAutosave: clearAutosaveLocal,
|
||||
createPostPayload,
|
||||
markAutosaveFailed,
|
||||
markSaved,
|
||||
allowNextRouteLeave,
|
||||
takePendingAutosavePayload
|
||||
@@ -2230,58 +2409,82 @@ defineExpose({
|
||||
aria-modal="true"
|
||||
@click.self="closeMediaPicker"
|
||||
>
|
||||
<section class="admin-post-form__media-picker-panel flex max-h-[86vh] min-h-[620px] w-full max-w-5xl flex-col overflow-hidden bg-white text-ink shadow-xl">
|
||||
<section class="admin-post-form__media-picker-panel flex h-[min(86vh,760px)] min-h-[620px] w-full max-w-5xl flex-col overflow-hidden bg-white text-ink shadow-xl">
|
||||
<div class="admin-post-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
|
||||
<h2 class="admin-post-form__media-picker-title text-lg font-semibold">
|
||||
대표 이미지
|
||||
</h2>
|
||||
<button class="admin-post-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
|
||||
<button
|
||||
class="admin-post-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="isUploadingFeaturedImage"
|
||||
@click="closeMediaPicker"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-post-form__media-picker-tabs flex border-b border-line px-5">
|
||||
<button
|
||||
class="admin-post-form__media-picker-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
|
||||
:class="activeMediaPickerTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-muted hover:text-[#15171a]'"
|
||||
type="button"
|
||||
@click="activeMediaPickerTab = 'upload'"
|
||||
>
|
||||
이미지 업로드
|
||||
</button>
|
||||
<button
|
||||
class="admin-post-form__media-picker-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
|
||||
:class="activeMediaPickerTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-muted hover:text-[#15171a]'"
|
||||
type="button"
|
||||
@click="activeMediaPickerTab = 'library'"
|
||||
>
|
||||
미디어 라이브러리
|
||||
</button>
|
||||
<div class="admin-post-form__media-picker-tabs flex items-center gap-3 border-b border-line px-5">
|
||||
<div class="flex min-w-0 flex-1 items-center">
|
||||
<button
|
||||
class="admin-post-form__media-picker-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
|
||||
:class="activeMediaPickerTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-muted hover:text-[#15171a]'"
|
||||
type="button"
|
||||
@click="activeMediaPickerTab = 'upload'"
|
||||
>
|
||||
이미지 업로드
|
||||
</button>
|
||||
<button
|
||||
class="admin-post-form__media-picker-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
|
||||
:class="activeMediaPickerTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-muted hover:text-[#15171a]'"
|
||||
type="button"
|
||||
@click="activeMediaPickerTab = 'library'"
|
||||
>
|
||||
미디어 라이브러리
|
||||
</button>
|
||||
</div>
|
||||
<label v-if="activeMediaPickerTab === 'library'" class="admin-post-form__media-picker-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-post-form__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="onMediaSearchInput"
|
||||
@compositionupdate="onMediaSearchInput"
|
||||
@compositionend="onMediaSearchCompositionEnd"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-post-form__media-picker-body flex-1 overflow-y-auto p-5">
|
||||
<div class="admin-post-form__media-picker-body min-h-0 flex-1 overflow-y-auto p-5">
|
||||
<div
|
||||
v-if="activeMediaPickerTab === 'upload'"
|
||||
class="admin-post-form__media-upload-zone grid min-h-[420px] place-items-center border border-dashed border-[#cfd5da] bg-white text-center"
|
||||
:class="isUploadingFeaturedImage ? 'select-none border-[#8e9cac] bg-[#f3f5f7]' : ''"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropFeaturedImage"
|
||||
>
|
||||
<div class="admin-post-form__media-upload-inner grid gap-3">
|
||||
<p class="admin-post-form__media-upload-title text-lg font-semibold text-[#15171a]">
|
||||
파일을 끌어 업로드
|
||||
{{ isUploadingFeaturedImage ? '업로드 중입니다' : '파일을 끌어 업로드' }}
|
||||
</p>
|
||||
<p class="admin-post-form__media-upload-or text-sm text-muted">
|
||||
또는
|
||||
{{ isUploadingFeaturedImage ? '잠시만 기다려 주세요.' : '또는' }}
|
||||
</p>
|
||||
<label class="admin-post-form__media-upload-button mx-auto inline-flex h-10 cursor-pointer items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50">
|
||||
<label
|
||||
class="admin-post-form__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="isUploadingFeaturedImage ? 'cursor-not-allowed border-[#c8ced3] text-[#8e9cac] hover:bg-transparent' : 'cursor-pointer'"
|
||||
>
|
||||
{{ isUploadingFeaturedImage ? '업로드 중' : '파일 선택' }}
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||
<input class="sr-only" type="file" accept="image/*" :disabled="isUploadingFeaturedImage" @change="uploadFeaturedImage">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p v-if="isLoadingMedia" class="admin-post-form__media-picker-loading text-sm text-muted">
|
||||
<p v-if="isLoadingMedia" class="admin-post-form__media-picker-loading py-12 text-center text-sm text-muted">
|
||||
미디어를 불러오는 중입니다.
|
||||
</p>
|
||||
<div v-else-if="featuredImageMediaItems.length" class="admin-post-form__media-picker-grid grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
|
||||
<div v-else-if="featuredImageMediaItems.length" class="admin-post-form__media-picker-grid grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<button
|
||||
v-for="item in featuredImageMediaItems"
|
||||
:key="item.url"
|
||||
@@ -2291,8 +2494,14 @@ defineExpose({
|
||||
:aria-pressed="selectedMediaPickerUrl === item.url"
|
||||
@click="selectPickedImage(item)"
|
||||
>
|
||||
<img class="admin-post-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||
<span class="admin-post-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
|
||||
<img
|
||||
class="admin-post-form__media-picker-image aspect-[4/3] w-full bg-surface object-cover"
|
||||
:src="getFeaturedMediaPreviewUrl(item)"
|
||||
:alt="item.name || ''"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
<span class="admin-post-form__media-picker-name block truncate px-3 py-2 text-xs font-semibold text-ink">{{ item.name || item.url }}</span>
|
||||
<span v-if="selectedMediaPickerUrl === item.url" class="admin-post-form__media-picker-selected absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[#15171a] text-white" aria-hidden="true">
|
||||
<svg width="13" height="10" viewBox="0 0 13 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 5L4.5 8.5L12 1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
@@ -2301,15 +2510,56 @@ defineExpose({
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="admin-post-form__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
|
||||
선택할 미디어가 없습니다.
|
||||
<template v-if="mediaSearchQuery.trim()">
|
||||
「{{ mediaSearchQuery.trim() }}」에 맞는 미디어가 없습니다.
|
||||
</template>
|
||||
<template v-else>
|
||||
선택할 미디어가 없습니다.
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<div class="admin-post-form__media-picker-footer flex h-14 shrink-0 items-center justify-end border-t border-line px-5">
|
||||
<div class="admin-post-form__media-picker-footer flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-t border-line px-5 py-3">
|
||||
<div class="admin-post-form__media-picker-pagination flex flex-wrap items-center gap-2 text-xs font-semibold text-muted">
|
||||
<span v-if="activeMediaPickerTab === 'library'">{{ mediaPickerRangeLabel }}</span>
|
||||
<nav v-if="activeMediaPickerTab === 'library' && mediaPickerPageCount > 1" class="flex flex-wrap items-center gap-1" aria-label="대표 이미지 선택 페이지">
|
||||
<button
|
||||
class="admin-post-form__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="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-post-form__media-picker-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-post-form__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="isLoadingMedia || mediaPickerPage >= mediaPickerPageCount"
|
||||
aria-label="다음 페이지"
|
||||
@click="setMediaPickerPage(mediaPickerPage + 1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<button
|
||||
class="admin-post-form__media-picker-confirm h-9 rounded bg-[#15171a] px-4 text-sm font-semibold text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:bg-[#d7dce0] disabled:text-[#8e9cac]"
|
||||
type="button"
|
||||
:disabled="!selectedMediaPickerUrl"
|
||||
:disabled="isUploadingFeaturedImage || !selectedMediaPickerUrl"
|
||||
@click="applyPickedImage"
|
||||
>
|
||||
대표 이미지로 적용
|
||||
|
||||
Reference in New Issue
Block a user