에디터 미디어 모달 컴포넌트 분리

This commit is contained in:
2026-07-24 18:53:56 +09:00
parent 48982cfa52
commit 84cb3ad9e7
8 changed files with 378 additions and 210 deletions

View File

@@ -33,12 +33,11 @@ import {
getMediaPickerAcceptForTarget,
getMediaPickerKindForTarget,
getGallerySelectionOrder as getGallerySelectionOrderFromSelection,
getMediaItemKind,
getMediaPickerPreviewUrl,
getMediaPickerTitleForTarget,
isGalleryMediaPickerTarget,
isMediaItemSelectableForTarget,
isUploadFileAllowedForMediaTarget,
normalizeMediaPickerPage,
normalizeMediaPickerResponse,
resolveSelectedMediaItems,
resolveUploadFetchErrorMessage,
@@ -3076,7 +3075,7 @@ const getGallerySelectionOrder = (url) => {
* @returns {Promise<void>}
*/
const setMediaPickerPage = async (page) => {
mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value)
mediaPickerPage.value = normalizeMediaPickerPage(page, mediaPickerPageCount.value)
await fetchMediaItems()
}
@@ -3627,206 +3626,36 @@ const handleKeydown = (event) => {
</div>
</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 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">
{{ mediaPickerTitle }}
</h2>
</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"
type="button"
:disabled="isUploading"
@click="closeMediaPicker"
>
닫기
</button>
</header>
<div class="admin-markdown-editor__media-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-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'upload'"
>
업로드
</button>
<button
class="admin-markdown-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'library'"
>
미디어 라이브러리
</button>
</div>
<label v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-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-markdown-editor__media-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-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]">
불러오는 중
</div>
<div v-else-if="filteredMediaItems.length" class="admin-markdown-editor__media-grid grid grid-cols-2 gap-3 md:grid-cols-4">
<button
v-for="item in filteredMediaItems"
:key="item.url"
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"
:src="getMediaPickerPreviewUrl(item)"
:alt="item.name || ''"
loading="lazy"
decoding="async"
@error="onMediaPickerImageError($event, item)"
>
<span
v-else
class="admin-markdown-editor__media-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-editor__media-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-editor__media-empty py-12 text-center text-sm text-[#8e9cac]">
{{ mediaPickerEmptyMessage }}
</div>
</template>
<div
v-else
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"
>
<div class="admin-markdown-editor__media-upload-inner grid gap-3 px-6">
<p class="admin-markdown-editor__media-upload-title text-lg font-semibold text-[#15171a]">
{{ isUploading ? '업로드 중입니다' : '파일을 끌어 업로드' }}
</p>
<p class="admin-markdown-editor__media-upload-or text-sm text-[#6b7280]">
{{ isUploading ? '잠시만 기다려 주세요.' : '또는' }}
</p>
<label
class="admin-markdown-editor__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="isUploading ? 'cursor-not-allowed border-[#c8ced3] text-[#8e9cac] hover:bg-transparent' : 'cursor-pointer'"
>
{{ isUploading ? '업로드 중' : '파일 선택' }}
<input
class="sr-only"
type="file"
:accept="mediaPickerAccept"
:multiple="isGalleryMediaPicker"
:disabled="isUploading"
@change="uploadFromMediaModal($event.target.files); $event.target.value = ''"
>
</label>
<p class="admin-markdown-editor__media-upload-hint text-xs text-[#8e9cac]">
{{ mediaPickerUploadHint }}
</p>
</div>
<div
v-if="isUploading"
class="admin-markdown-editor__media-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-editor__media-upload-loading grid w-full max-w-sm gap-4 px-8 text-center">
<span class="admin-markdown-editor__media-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-editor__media-upload-skeleton h-2 overflow-hidden rounded-full bg-[#e3e6e8]" aria-hidden="true" />
</div>
</div>
</div>
</div>
<footer v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-footer flex flex-wrap items-center justify-between gap-3 border-t border-[#e3e6e8] px-5 py-4">
<div class="admin-markdown-editor__media-pagination flex flex-wrap items-center gap-2 text-xs font-semibold text-[#6b7280]">
<span>{{ mediaPickerRangeLabel }}</span>
<nav v-if="mediaPickerPageCount > 1" class="flex flex-wrap items-center gap-1" aria-label="미디어 선택 페이지">
<button
class="admin-markdown-editor__media-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-markdown-editor__media-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-markdown-editor__media-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>
<div class="admin-markdown-editor__media-actions flex items-center justify-end gap-2">
<button
class="admin-markdown-editor__media-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="closeMediaPicker"
>
취소
</button>
<button
class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="mediaPickerApplyState.disabled"
@click="applyMediaSelection"
>
{{ mediaPickerApplyState.label }}
</button>
</div>
</footer>
</div>
</div>
<AdminMarkdownMediaPickerModal
v-if="isMediaPickerOpen"
:title="mediaPickerTitle"
:active-tab="activeMediaPickerTab"
:search-input="mediaSearchInput"
:media-items="filteredMediaItems"
:selected-urls="selectedMediaUrls"
:is-loading="isLoadingMedia"
:is-uploading="isUploading"
:accept="mediaPickerAccept"
:multiple="isGalleryMediaPicker"
:upload-hint="mediaPickerUploadHint"
:empty-message="mediaPickerEmptyMessage"
:range-label="mediaPickerRangeLabel"
:page="mediaPickerPage"
:page-count="mediaPickerPageCount"
:page-items="mediaPickerPageItems"
:apply-state="mediaPickerApplyState"
:get-selection-order="getGallerySelectionOrder"
@apply="applyMediaSelection"
@close="closeMediaPicker"
@drop="handleMediaModalDrop"
@image-error="onMediaPickerImageError($event.event, $event.item)"
@page="setMediaPickerPage"
@search-composition-end="onMediaSearchCompositionEnd"
@search-input="onMediaSearchInput"
@tab="activeMediaPickerTab = $event"
@toggle="toggleMediaSelection"
@upload="uploadFromMediaModal"
/>
</div>
<AdminToast :toast="toast" />

View File

@@ -0,0 +1,315 @@
<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>

View File

@@ -48,7 +48,7 @@
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
| lib/markdown-editor-history.js | 관리자 Markdown-first 에디터 undo/redo 히스토리 항목 생성, 레거시 문자열 항목 본문 추출, 선택 범위 보정 |
| lib/markdown-editor-media.js | 관리자 Markdown-first 에디터 미디어 선택 모달의 미디어 종류 판별, 대상별 선택 가능 여부, 미리보기 URL, 업로드 MIME 허용, 선택 대상별 제목·accept·API kind·업로드 안내, 라이브러리 빈 상태 문구, 적용 버튼 상태, 선택 순서·토글·항목 해석, 업로드 오류·한도 검증, 페이지 상태·범위·페이지 번호 계산, fenced 미디어 블록 생성 |
| lib/markdown-editor-media.js | 관리자 Markdown-first 에디터 미디어 선택 모달의 미디어 종류 판별, 대상별 선택 가능 여부, 미리보기 URL, 업로드 MIME 허용, 선택 대상별 제목·accept·API kind·업로드 안내, 라이브러리 빈 상태 문구, 적용 버튼 상태, 선택 순서·토글·항목 해석, 업로드 오류·한도 검증, 페이지 상태·범위·페이지 번호 보정·페이지 번호 계산, fenced 미디어 블록 생성 |
| lib/markdown-block-parser.js | 공개 Markdown 본문 문자열을 제목·문단·이미지·갤러리·fenced 블록·인용·목록·코드·표 렌더링 블록으로 변환 |
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·링크·iframe·HTML·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 인용 옵션 공통 파서 연결, 코드 fenced 블록 여닫는 펜스 쌍 판별 |
| lib/markdown-iframe-embed.js | 게시물 본문 iframe 블록의 코드 판별과 안전 속성 추출, `allowfullscreen`을 `allow=fullscreen` 권한으로 정규화 |
@@ -218,6 +218,7 @@
| components/admin/AdminMediaDetailModal.vue | 관리자 미디어 화면의 상세 모달(미리보기·파일 정보·썸네일 상태·폴더·사용 현황·파일명·삭제 액션 표시) |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·자동 저장 실패/진행 중 이탈 차단·신규 임시 슬러그·중복 슬러그 필드 하단 오류 표시·중복 슬러그 중 Publish/Update/최종 저장 비활성화·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 설정 패널 상태와 이벤트 연결, 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminMarkdownMediaPickerModal.vue | 관리자 Markdown 에디터 미디어 선택 모달 UI(업로드·라이브러리 탭, 검색 입력, 미디어 그리드, 업로드 영역, 페이지네이션, 적용/취소 버튼) |
| components/admin/AdminPostSettingsSidebar.vue | 관리자 게시물 작성/수정 우측 설정 사이드바(Post URL·상태·발행 시각·요약·태그·대표 이미지 표시·추천 글·검색엔진 노출·본문 통계·삭제 버튼·블록 설정 패널 오버레이) |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 저장 payload 기준 미저장 비교·라우트/브라우저 이탈 확인, 페이지 공개 상태 선택, 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, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.11.78
- 관리자 Markdown 에디터의 미디어 선택 모달 UI를 `AdminMarkdownMediaPickerModal` 컴포넌트로 분리.
- 미디어 선택 페이지 번호 보정 helper 추가 및 단위 테스트 보강.
## v1.11.77
- 관리자 Markdown 에디터 미디어 선택 적용 버튼 상태 계산을 공통 helper로 분리.

View File

@@ -434,6 +434,17 @@ export const findUploadSizeLimitError = ({
return null
}
/**
* 미디어 선택 페이지 번호를 유효 범위로 보정한다.
* @param {number|string} page - 요청 페이지
* @param {number|string} pageCount - 전체 페이지 수
* @returns {number} 보정된 페이지
*/
export const normalizeMediaPickerPage = (page, pageCount) => {
const total = Math.max(1, Number(pageCount) || 1)
return Math.min(Math.max(Number(page) || 1, 1), total)
}
/**
* 미디어 선택 모달의 페이지 범위 표시 문구를 만든다.
* @param {Object} options - 범위 표시 옵션
@@ -455,8 +466,7 @@ export const buildMediaPickerRangeLabel = ({
return '0개'
}
const normalizedPageCount = Math.max(1, Number(pageCount) || 1)
const page = Math.min(Math.max(Number(pagination?.page) || currentPage, 1), normalizedPageCount)
const page = normalizeMediaPickerPage(Number(pagination?.page) || currentPage, pageCount)
const pageSize = Number(pagination?.pageSize) || defaultPageSize
const start = ((page - 1) * pageSize) + 1
const end = Math.min(start + pageSize - 1, total)
@@ -476,7 +486,7 @@ export const buildMediaPickerPageItems = ({
pageCount
}) => {
const total = Math.max(1, Number(pageCount) || 1)
const current = Math.min(Math.max(Number(currentPage) || 1, 1), total)
const current = normalizeMediaPickerPage(currentPage, total)
const pages = new Set([1, total, current])
for (let page = current - 2; page <= current + 2; page += 1) {

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.77",
"version": "1.11.78",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.77",
"version": "1.11.78",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.11.77",
"version": "1.11.78",
"private": true,
"type": "module",
"imports": {

View File

@@ -19,6 +19,7 @@ import {
isGalleryMediaPickerTarget,
isMediaItemSelectableForTarget,
isUploadFileAllowedForMediaTarget,
normalizeMediaPickerPage,
normalizeMediaPickerResponse,
resolveSelectedMediaItems,
resolveUploadFetchErrorMessage,
@@ -273,6 +274,13 @@ test('업로드 파일 한도 초과 메시지를 계산한다', () => {
}), null)
})
test('미디어 선택 페이지 번호를 유효 범위로 보정한다', () => {
assert.equal(normalizeMediaPickerPage(3, 5), 3)
assert.equal(normalizeMediaPickerPage('99', 5), 5)
assert.equal(normalizeMediaPickerPage(-1, 5), 1)
assert.equal(normalizeMediaPickerPage('bad', 0), 1)
})
test('미디어 선택 페이지 범위 문구를 계산한다', () => {
assert.equal(buildMediaPickerRangeLabel({
pagination: {