관리자 글쓰기 슬러그와 미디어 선택 UX 개선
This commit is contained in:
@@ -3256,6 +3256,20 @@ const mediaPickerPageItems = computed(() => {
|
||||
}, [])
|
||||
})
|
||||
|
||||
/**
|
||||
* 갤러리 미디어 선택 순서를 반환한다.
|
||||
* @param {string} url - 미디어 URL
|
||||
* @returns {number} 1부터 시작하는 선택 순서, 선택되지 않았으면 0
|
||||
*/
|
||||
const getGallerySelectionOrder = (url) => {
|
||||
if (!isGalleryMediaPicker.value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const index = selectedMediaUrls.value.indexOf(url)
|
||||
return index >= 0 ? index + 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 선택 모달 페이지를 변경한다.
|
||||
* @param {number} page - 이동할 페이지
|
||||
@@ -3896,9 +3910,6 @@ const handleKeydown = (event) => {
|
||||
<h2 class="admin-markdown-editor__media-title text-lg font-bold text-black">
|
||||
{{ mediaPickerTitle }}
|
||||
</h2>
|
||||
<p v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-count mt-1 text-sm text-[#6b7280]">
|
||||
{{ selectedMediaUrls.length }}개 선택됨
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="admin-markdown-editor__media-close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
@@ -3953,11 +3964,17 @@ const handleKeydown = (event) => {
|
||||
<button
|
||||
v-for="item in filteredMediaItems"
|
||||
:key="item.url"
|
||||
class="admin-markdown-editor__media-item group overflow-hidden rounded border bg-white text-left transition"
|
||||
class="admin-markdown-editor__media-item group relative overflow-hidden rounded border bg-white text-left transition"
|
||||
:class="selectedMediaUrls.includes(item.url) ? 'border-[3px] border-[#ff7a00] ring-2 ring-[#ff7a00]/40' : 'border border-[#e3e6e8] hover:border-[#8e9cac]'"
|
||||
type="button"
|
||||
@click="toggleMediaSelection(item)"
|
||||
>
|
||||
<span
|
||||
v-if="getGallerySelectionOrder(item.url)"
|
||||
class="admin-markdown-editor__media-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"
|
||||
>
|
||||
{{ getGallerySelectionOrder(item.url) }}
|
||||
</span>
|
||||
<img
|
||||
v-if="getMediaItemKind(item) === 'image'"
|
||||
class="admin-markdown-editor__media-thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover"
|
||||
@@ -3989,7 +4006,7 @@ const handleKeydown = (event) => {
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="admin-markdown-editor__media-upload-zone relative grid min-h-[420px] place-items-center overflow-hidden rounded border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
|
||||
class="admin-markdown-editor__media-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-editor__media-upload-zone--uploading select-none border-[#8e9cac] bg-[#f3f5f7]' : ''"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleMediaModalDrop"
|
||||
|
||||
@@ -97,6 +97,7 @@ const mediaPickerPagination = ref({
|
||||
})
|
||||
const savedPostSnapshot = ref('')
|
||||
const failedDraftAutosaveSnapshot = ref('')
|
||||
const slugFieldError = ref('')
|
||||
const isPublishModalOpen = ref(false)
|
||||
const isUnpublishModalOpen = ref(false)
|
||||
const publishStatus = ref('draft')
|
||||
@@ -369,6 +370,7 @@ const slugInputValue = computed({
|
||||
set: (value) => {
|
||||
slugTouched.value = true
|
||||
form.slug = value
|
||||
slugFieldError.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -743,6 +745,9 @@ const hasUnsavedPostChanges = computed(() => serializePostPayload() !== savedPos
|
||||
|
||||
const serializedPostFingerprint = computed(() => serializePostPayload())
|
||||
|
||||
/** 중복 슬러그처럼 저장을 막아야 하는 필드 오류가 있는지 여부 */
|
||||
const hasBlockingSlugError = computed(() => Boolean(slugFieldError.value))
|
||||
|
||||
const isFailedDraftAutosavePayload = computed(() => Boolean(
|
||||
failedDraftAutosaveSnapshot.value
|
||||
&& failedDraftAutosaveSnapshot.value === serializedPostFingerprint.value
|
||||
@@ -1494,6 +1499,9 @@ const focusContentEditor = (event) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitPost = () => {
|
||||
if (hasBlockingSlugError.value) {
|
||||
return
|
||||
}
|
||||
isPublishModalOpen.value = false
|
||||
isUnpublishModalOpen.value = false
|
||||
emit('submit', createPostPayload({ allowNewPublishTimestamp: true }))
|
||||
@@ -1504,6 +1512,9 @@ const submitPost = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const requestToolbarUpdate = () => {
|
||||
if (hasBlockingSlugError.value) {
|
||||
return
|
||||
}
|
||||
emit('submit', createPostPayload())
|
||||
}
|
||||
|
||||
@@ -1579,6 +1590,9 @@ const syncPublishModalStateFromForm = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const openPublishModal = () => {
|
||||
if (hasBlockingSlugError.value) {
|
||||
return
|
||||
}
|
||||
syncPublishModalStateFromForm()
|
||||
publishModalExpandedSection.value = null
|
||||
isPublishModalOpen.value = true
|
||||
@@ -1617,6 +1631,9 @@ const applyPublishSelectionToForm = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitFromPublishModal = () => {
|
||||
if (hasBlockingSlugError.value) {
|
||||
return
|
||||
}
|
||||
applyPublishSelectionToForm()
|
||||
submitPost()
|
||||
}
|
||||
@@ -1727,6 +1744,7 @@ const unpublishModalKind = computed(() => (
|
||||
const markSaved = (savedPayload = null) => {
|
||||
savedPostSnapshot.value = serializePostPayload(savedPayload)
|
||||
failedDraftAutosaveSnapshot.value = ''
|
||||
slugFieldError.value = ''
|
||||
const publishedAt = resolvePayloadPublishedAt()
|
||||
|
||||
if (publishedAt) {
|
||||
@@ -1743,6 +1761,15 @@ const markAutosaveFailed = (failedPayload = null) => {
|
||||
failedDraftAutosaveSnapshot.value = serializePostPayload(failedPayload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬러그 입력 필드 아래에 표시할 오류 문구를 설정한다.
|
||||
* @param {string} message - 오류 문구
|
||||
* @returns {void}
|
||||
*/
|
||||
const markSlugError = (message) => {
|
||||
slugFieldError.value = message
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바운스 타이머를 취소하고, 즉시 서버로 보낼 초안 페이로드가 있으면 반환한다(라우트 이탈 플러시용)
|
||||
* @returns {Object|null} POST/PUT 본문 또는 null
|
||||
@@ -1863,6 +1890,7 @@ defineExpose({
|
||||
clearAutosave: clearAutosaveLocal,
|
||||
createPostPayload,
|
||||
markAutosaveFailed,
|
||||
markSlugError,
|
||||
markSaved,
|
||||
allowNextRouteLeave,
|
||||
takePendingAutosavePayload
|
||||
@@ -1921,8 +1949,9 @@ defineExpose({
|
||||
<template v-if="persistedPublishKind === 'draft'">
|
||||
<template v-if="isDraftLike">
|
||||
<button
|
||||
class="admin-post-form__toolbar-publish rounded px-3 py-1.5 text-sm font-bold text-[#2BBA3C] transition-colors hover:bg-[#f1f3f4]"
|
||||
class="admin-post-form__toolbar-publish rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#2BBA3C] enabled:hover:bg-[#f1f3f4]"
|
||||
type="button"
|
||||
:disabled="hasBlockingSlugError || saving || autoSaving"
|
||||
@click="openPublishModal"
|
||||
>
|
||||
Publish
|
||||
@@ -1932,7 +1961,7 @@ defineExpose({
|
||||
v-else
|
||||
class="admin-post-form__toolbar-update rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
|
||||
type="button"
|
||||
:disabled="!hasUnsavedPostChanges || saving || autoSaving"
|
||||
:disabled="!hasUnsavedPostChanges || hasBlockingSlugError || saving || autoSaving"
|
||||
@click="requestToolbarUpdate"
|
||||
>
|
||||
Update
|
||||
@@ -1943,7 +1972,7 @@ defineExpose({
|
||||
<button
|
||||
class="admin-post-form__toolbar-update rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
|
||||
type="button"
|
||||
:disabled="!hasUnsavedPostChanges || saving || autoSaving"
|
||||
:disabled="!hasUnsavedPostChanges || hasBlockingSlugError || saving || autoSaving"
|
||||
@click="requestToolbarUpdate"
|
||||
>
|
||||
Update
|
||||
@@ -1962,7 +1991,7 @@ defineExpose({
|
||||
<button
|
||||
class="admin-post-form__toolbar-update rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
|
||||
type="button"
|
||||
:disabled="!hasUnsavedPostChanges || saving || autoSaving"
|
||||
:disabled="!hasUnsavedPostChanges || hasBlockingSlugError || saving || autoSaving"
|
||||
@click="requestToolbarUpdate"
|
||||
>
|
||||
Update
|
||||
@@ -1981,7 +2010,7 @@ defineExpose({
|
||||
<button
|
||||
class="admin-post-form__toolbar-update rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
|
||||
type="button"
|
||||
:disabled="!hasUnsavedPostChanges || saving || autoSaving"
|
||||
:disabled="!hasUnsavedPostChanges || hasBlockingSlugError || saving || autoSaving"
|
||||
@click="requestToolbarUpdate"
|
||||
>
|
||||
Update
|
||||
@@ -2091,7 +2120,7 @@ defineExpose({
|
||||
type="text"
|
||||
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
||||
:required="!isSlugAutoFromTitle"
|
||||
:aria-describedby="isSlugAutoFromTitle ? 'admin-post-form-slug-auto-hint' : undefined"
|
||||
:aria-describedby="slugFieldError ? 'admin-post-form-slug-error' : (isSlugAutoFromTitle ? 'admin-post-form-slug-auto-hint' : undefined)"
|
||||
@blur="commitSlugInput"
|
||||
>
|
||||
</label>
|
||||
@@ -2106,6 +2135,13 @@ defineExpose({
|
||||
{{ postUrlHint }}
|
||||
<span v-if="hasUnsavedSlugChange" class="admin-post-form__post-url-hint-unsaved text-[#8e9cac]"> (저장 후 반영)</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="slugFieldError"
|
||||
id="admin-post-form-slug-error"
|
||||
class="admin-post-form__post-url-error text-xs font-semibold text-red-600"
|
||||
>
|
||||
{{ slugFieldError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="admin-post-form__field grid gap-2 text-sm">
|
||||
@@ -2459,7 +2495,7 @@ defineExpose({
|
||||
<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="admin-post-form__media-upload-zone grid h-full min-h-[420px] place-items-center border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
|
||||
:class="isUploadingFeaturedImage ? 'select-none border-[#8e9cac] bg-[#f3f5f7]' : ''"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropFeaturedImage"
|
||||
@@ -2753,7 +2789,7 @@ defineExpose({
|
||||
<button
|
||||
class="rounded bg-[#15171a] px-5 py-2.5 text-[14px] font-semibold text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:bg-[#d7dce0] disabled:text-[#8e9cac]"
|
||||
type="button"
|
||||
:disabled="saving || autoSaving"
|
||||
:disabled="hasBlockingSlugError || saving || autoSaving"
|
||||
@click="submitFromPublishModal"
|
||||
>
|
||||
{{ saving || autoSaving ? '저장 중…' : '최종 확인하고 저장 →' }}
|
||||
|
||||
Reference in New Issue
Block a user