관리자 글쓰기 슬러그와 미디어 선택 UX 개선

This commit is contained in:
2026-07-08 12:37:42 +09:00
parent 976027c808
commit eb840b4224
10 changed files with 131 additions and 21 deletions

View File

@@ -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"

View File

@@ -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 ? '저장 중…' : '최종 확인하고 저장 →' }}

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.10.56에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.10.59에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형

View File

@@ -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, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·신규 임시 슬러그·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·신규 임시 슬러그·중복 슬러그 필드 하단 오류 표시·중복 슬러그 중 Publish/Update/최종 저장 비활성화·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `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 | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |

View File

@@ -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` 링크에서만 제공한다. 초안 자동 저장이 중복 슬러그 등으로 실패하면 같은 입력값은 반복 재시도하지 않고, 사용자가 슬러그나 본문을 바꿔 저장 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` 서버 페이지 응답, 파일명/사용처 검색, 번호형 페이지 이동, 작은 파생 이미지 우선 미리보기를 사용한다. 두 글쓰기 미디어 모달은 검색 결과 개수와 무관하게 패널 높이를 고정하고 내부 목록만 스크롤한다.
- 관리자 글쓰기 상단 왼쪽 상태 영역은 `Published`, `Scheduled`, `Members`, `Private`, 초안 저장 상태 등 현재 상태 텍스트만 표시하며, 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서만 제공한다. 초안 자동 저장이 중복 슬러그 등으로 실패하면 같은 입력값은 반복 재시도하지 않고, 사용자가 슬러그나 본문을 바꿔 저장 payload가 달라졌을 때 다시 자동 저장을 시도한다. 중복 슬러그 오류는 헤더 오류나 토스트가 아니라 Post URL 입력창 아래 빨간 텍스트로 표시하고, 슬러그를 다시 입력하면 숨긴다. 중복 슬러그 오류가 표시된 동안 Publish/Update/최종 저장 버튼은 비활성화한다. 오른쪽 설정 패널 하단에는 본문 기준 단어 수, 공백 제외 문자 수와 공백 수, 예상 읽기 시간, 블록 수, 이미지 수를 작은 통계로 표시한다.
- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/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 아이콘을 사용한다.
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.

View File

@@ -1,5 +1,18 @@
# 업데이트 이력
## v1.10.59
- 글쓰기 이미지/갤러리 미디어 모달 헤더의 선택 개수 문구를 제거해 탭 전환 시 레이아웃 흔들림을 줄임.
- 갤러리 미디어 선택 항목 오른쪽 상단에 선택 순서 배지를 표시하도록 수정.
## v1.10.58
- 글쓰기 중복 슬러그 오류가 표시된 동안 Publish/Update/최종 저장 버튼을 비활성화하도록 수정.
## v1.10.57
- 글쓰기 중복 슬러그 오류를 헤더/토스트 대신 Post URL 입력창 아래 빨간 텍스트로 표시하도록 수정.
## v1.10.56
- 초안 자동 저장이 중복 슬러그 오류로 실패한 뒤 같은 입력값을 반복 재시도해 화면이 깜빡이던 문제 수정.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -46,6 +46,14 @@ const showToast = (type, message) => {
}, 3200)
}
/**
* 저장 오류가 슬러그 중복인지 확인한다.
* @param {unknown} error - 저장 오류
* @returns {boolean} 슬러그 중복 오류 여부
*/
const isDuplicateSlugError = (error) => Number(error?.statusCode || error?.status || error?.response?.status) === 409
&& String(error?.data?.message || error?.message || '').includes('슬러그')
/**
* 이전 화면에서 전달한 토스트 표시
* @returns {void}
@@ -100,6 +108,13 @@ const savePost = async (payload) => {
postForm.value?.clearAutosave()
showToast('success', '변경 내용이 저장되었습니다.')
} catch (error) {
if (isDuplicateSlugError(error)) {
window.clearTimeout(toastTimer)
toast.value = null
errorMessage.value = ''
postForm.value?.markSlugError('이미 사용 중인 슬러그입니다.')
return
}
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
showToast('error', errorMessage.value)
} finally {
@@ -129,6 +144,13 @@ const autosaveDraftPost = async (payload) => {
} catch (error) {
const msg = error?.data?.message || error?.message || '자동 저장에 실패했습니다.'
postForm.value?.markAutosaveFailed(payload)
if (isDuplicateSlugError(error)) {
window.clearTimeout(toastTimer)
toast.value = null
errorMessage.value = ''
postForm.value?.markSlugError('이미 사용 중인 슬러그입니다.')
return
}
showToast('error', msg)
} finally {
autoSaving.value = false

View File

@@ -72,6 +72,14 @@ const showToast = (type, message) => {
}, 3200)
}
/**
* 저장 오류가 슬러그 중복인지 확인한다.
* @param {unknown} error - 저장 오류
* @returns {boolean} 슬러그 중복 오류 여부
*/
const isDuplicateSlugError = (error) => Number(error?.statusCode || error?.status || error?.response?.status) === 409
&& String(error?.data?.message || error?.message || '').includes('슬러그')
/**
* 새 게시물 미리보기
* @param {Object} payload - 게시물 입력값
@@ -111,6 +119,13 @@ const savePost = async (payload) => {
}))
await navigateTo(`/admin/posts/${post.id}`)
} catch (error) {
if (isDuplicateSlugError(error)) {
window.clearTimeout(toastTimer)
toast.value = null
errorMessage.value = ''
postForm.value?.markSlugError('이미 사용 중인 슬러그입니다.')
return
}
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
showToast('error', errorMessage.value)
} finally {
@@ -149,6 +164,13 @@ const createDraftAutosave = async (payload, options = {}) => {
} catch (error) {
const msg = error?.data?.message || error?.message || '자동 저장에 실패했습니다.'
postForm.value?.markAutosaveFailed(payload)
if (isDuplicateSlugError(error)) {
window.clearTimeout(toastTimer)
toast.value = null
errorMessage.value = ''
postForm.value?.markSlugError('이미 사용 중인 슬러그입니다.')
return
}
errorMessage.value = msg
showToast('error', msg)
} finally {