관리자 미디어 선택과 초안 자동 저장 개선

This commit is contained in:
2026-07-08 12:12:47 +09:00
parent 2e5a973d11
commit 976027c808
11 changed files with 319 additions and 54 deletions

View File

@@ -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"
>
대표 이미지로 적용