관리자 미디어 선택과 초안 자동 저장 개선
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"
|
||||
>
|
||||
대표 이미지로 적용
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.10.51에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.10.56에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
## 2026-07-08 v1.10.56 — 글쓰기 미디어 선택은 하나의 서버 페이지 흐름으로 맞춘다
|
||||
|
||||
본문 이미지 삽입 모달은 이미 관리자 미디어 API의 페이지 응답과 검색을 사용하지만, 대표 이미지 선택 모달은 전체 미디어 배열을 받은 뒤 클라이언트에서 원본 이미지만 걸러 쓰는 레거시 방식이 남아 있었다. 미디어가 많아질수록 두 선택 화면의 성능과 검색 결과가 달라질 수 있으므로 대표 이미지 선택도 같은 `/admin/api/media?paged=1&tab=library` 흐름으로 통일했다.
|
||||
|
||||
또한 검색 결과 개수에 따라 모달 높이가 계속 변하면 입력 중 화면 기준점이 흔들린다. 글쓰기 미디어 모달은 결과 목록만 내부 스크롤되도록 고정 높이 패널로 다루고, 업로드/라이브러리 탭 전환과 검색 결과 변화가 모달 외곽 크기를 바꾸지 않게 한다.
|
||||
|
||||
## 2026-07-06 v1.10.52 — 사이트 용량 카드는 설정 화면에 맞게 간략화한다
|
||||
|
||||
설정 화면의 용량 요약은 백업 도구와 별개이므로 `백업 현황` 대신 `사이트 용량`으로 이름을 바꿨다. 파일 용량은 이미지·영상·보내기·합계 4행만 보여 주고, 자동 생성·프로필 이미지는 이미지 행의 보조 설명으로만 표시한다. 큰 파일 목록은 설정과 무관하다고 판단해 제거했다.
|
||||
|
||||
@@ -136,9 +136,9 @@
|
||||
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
|
||||
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
|
||||
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·신규 임시 슬러그·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 라이브러리 탭은 `/admin/api/media?paged=1` 서버 페이지 응답·파일명/사용처 제목 검색·번호형 페이지 이동, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 원본 URL 선택 유지·목록 미리보기는 720px 본문 파생 이미지 또는 카드 썸네일 우선, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 라이브러리 탭은 `/admin/api/media?paged=1` 서버 페이지 응답·파일명/사용처 제목 검색·번호형 페이지 이동, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 원본 URL 선택 유지·목록 미리보기는 720px 본문 파생 이미지 또는 카드 썸네일 우선, 검색 결과 수와 무관한 고정 높이 패널, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/HTML 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
|
||||
|
||||
@@ -205,8 +205,8 @@ layouts/
|
||||
- 관리자 글 목록의 추천 토글은 전체 게시물 저장 API가 아니라 `/admin/api/posts/:id/featured` 전용 PUT API에 `{ isFeatured }`만 보내 수정한다.
|
||||
- 관리자 글 목록의 대표 이미지 미리보기는 작은 카드 썸네일이 있으면 `featuredImageThumbnail`을 우선 사용하고, 없을 때만 원본 `featuredImage`를 fallback으로 사용한다.
|
||||
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 태그 추천 목록을 열 수 있다. 입력이 비어 있으면 공개 발행글 기준 최근 사용 태그 5개를 먼저 표시한 뒤 메인 태그를 이어서 보여 주고, 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천한다. 추천 목록은 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다. 게시물별 태그 최대 개수는 기본 5개이며, 사이트 설정 POST 설정의 `postTagLimit` 값으로 1~10개 범위에서 조절한다.
|
||||
- 관리자 글쓰기 상단 왼쪽 상태 영역은 `Published`, `Scheduled`, `Members`, `Private`, 초안 저장 상태 등 현재 상태 텍스트만 표시하며, 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서만 제공한다. 오른쪽 설정 패널 하단에는 본문 기준 단어 수, 공백 제외 문자 수와 공백 수, 예상 읽기 시간, 블록 수, 이미지 수를 작은 통계로 표시한다.
|
||||
- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 미디어 관리 화면은 `/admin/api/media?paged=1` 응답을 사용해 서버에서 탭·폴더·파일 종류·미사용·검색 조건을 적용한 뒤 50·100·200개 단위 페이지네이션을 제공하며 번호와 이전·다음 버튼으로 이동한다. 현재 페이지 선택은 화면에 표시된 삭제 가능 항목만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다. 미디어 그리드와 글쓰기 이미지 선택 모달은 원본 삽입·상세 확인은 유지하되, 목록 미리보기에는 가능한 작은 본문 720px 파생 이미지나 카드 썸네일을 우선 사용한다. 글쓰기 이미지 선택 모달의 라이브러리 탭도 `/admin/api/media?paged=1&tab=library` 서버 페이지 응답을 사용하며 파일명과 사용처 제목(게시물·페이지 제목) 검색, 번호형 페이지 이동을 제공한다.
|
||||
- 관리자 글쓰기 상단 왼쪽 상태 영역은 `Published`, `Scheduled`, `Members`, `Private`, 초안 저장 상태 등 현재 상태 텍스트만 표시하며, 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서만 제공한다. 초안 자동 저장이 중복 슬러그 등으로 실패하면 같은 입력값은 반복 재시도하지 않고, 사용자가 슬러그나 본문을 바꿔 저장 payload가 달라졌을 때 다시 자동 저장을 시도한다. 오른쪽 설정 패널 하단에는 본문 기준 단어 수, 공백 제외 문자 수와 공백 수, 예상 읽기 시간, 블록 수, 이미지 수를 작은 통계로 표시한다.
|
||||
- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 미디어 관리 화면은 `/admin/api/media?paged=1` 응답을 사용해 서버에서 탭·폴더·파일 종류·미사용·검색 조건을 적용한 뒤 50·100·200개 단위 페이지네이션을 제공하며 번호와 이전·다음 버튼으로 이동한다. 현재 페이지 선택은 화면에 표시된 삭제 가능 항목만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다. 미디어 그리드와 글쓰기 이미지 선택 모달은 원본 삽입·상세 확인은 유지하되, 목록 미리보기에는 가능한 작은 본문 720px 파생 이미지나 카드 썸네일을 우선 사용한다. 글쓰기 이미지 선택 모달의 라이브러리 탭도 `/admin/api/media?paged=1&tab=library` 서버 페이지 응답을 사용하며 파일명과 사용처 제목(게시물·페이지 제목) 검색, 번호형 페이지 이동을 제공한다. 게시글 대표 이미지 선택 모달도 `/admin/api/media?paged=1&tab=library&kind=image` 서버 페이지 응답, 파일명/사용처 검색, 번호형 페이지 이동, 작은 파생 이미지 우선 미리보기를 사용한다. 두 글쓰기 미디어 모달은 검색 결과 개수와 무관하게 패널 높이를 고정하고 내부 목록만 스크롤한다.
|
||||
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
|
||||
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
|
||||
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.10.56
|
||||
|
||||
- 초안 자동 저장이 중복 슬러그 오류로 실패한 뒤 같은 입력값을 반복 재시도해 화면이 깜빡이던 문제 수정.
|
||||
- 게시글 대표 이미지 선택 모달을 글쓰기 미디어 라이브러리와 같은 서버 페이지네이션·검색 방식으로 수정.
|
||||
- 대표 이미지 선택 모달 미리보기를 원본 대신 작은 본문/카드 파생 이미지 우선 표시로 정리.
|
||||
- 글쓰기 미디어 라이브러리와 대표 이미지 선택 모달 높이를 고정해 검색 결과 개수 변화에 따른 모달 크기 흔들림 완화.
|
||||
|
||||
## v1.10.55
|
||||
|
||||
- 사이트 용량 `기타` 행을 `오디오`·`첨부 파일`로 분리하고 영상·보내기와 동일한 실선 카드로 표시.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.51",
|
||||
"version": "1.10.56",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.51",
|
||||
"version": "1.10.56",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.55",
|
||||
"version": "1.10.56",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -128,6 +128,7 @@ const autosaveDraftPost = async (payload) => {
|
||||
postForm.value?.markSaved(payload)
|
||||
} catch (error) {
|
||||
const msg = error?.data?.message || error?.message || '자동 저장에 실패했습니다.'
|
||||
postForm.value?.markAutosaveFailed(payload)
|
||||
showToast('error', msg)
|
||||
} finally {
|
||||
autoSaving.value = false
|
||||
|
||||
@@ -148,6 +148,7 @@ const createDraftAutosave = async (payload, options = {}) => {
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error?.data?.message || error?.message || '자동 저장에 실패했습니다.'
|
||||
postForm.value?.markAutosaveFailed(payload)
|
||||
errorMessage.value = msg
|
||||
showToast('error', msg)
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user