v1.2.0: 관리자 글 목록·슬러그·예약 시각 UX 정리

발행일 기준 목록 정렬, 추천 필터·별 표시, 슬러그 자동/수동 구분, 예약 날짜·시간 클릭 영역 수정.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-15 17:48:19 +09:00
parent 6fd61911fd
commit 14ce897bf8
8 changed files with 303 additions and 92 deletions

View File

@@ -42,10 +42,17 @@ const emit = defineEmits(['submit', 'preview', 'delete', 'autosave'])
const autosaveStoragePrefix = 'SORI_ADMIN_POST_AUTOSAVE'
let draftPersistTimer = null
const slugTouched = ref(
Boolean(props.initialPost.slug)
&& !isAdminPostDraftPlaceholderSlug(props.initialPost.slug)
)
const slugTouched = ref((() => {
const post = props.initialPost
if (!post?.id) {
return false
}
const status = post.status === 'private' ? 'draft' : post.status
if (status === 'published') {
return true
}
return Boolean(post.slug) && !isAdminPostDraftPlaceholderSlug(post.slug)
})())
const blockEditor = ref(null)
/** 본문 에디터 모드: write | preview */
const editorMode = ref('write')
@@ -147,9 +154,100 @@ const form = reactive({
tagsText: props.initialPost.tags?.join(', ') || ''
})
/**
* 서버에 반영된 게시 형태(툴바·자동 저장·슬러그 자동 연동 분기)
* @param {Object} post - 게시물
* @returns {'draft' | 'publishedLive' | 'scheduled'}
*/
const getPersistedPublishKind = (post) => {
if (!post?.id) {
return 'draft'
}
const st = post.status === 'private' ? 'draft' : post.status
if (st !== 'published') {
return 'draft'
}
const pa = post.publishedAt
if (pa && new Date(pa) > new Date()) {
return 'scheduled'
}
return 'publishedLive'
}
/** 마지막으로 서버와 맞춘 게시 형태 */
const persistedPublishKind = ref(getPersistedPublishKind(props.initialPost))
/** 서버에 반영된 발행 시각(본문 Update 시 유지) */
const persistedPublishedAtIso = ref(props.initialPost.publishedAt || null)
watch(
() => props.initialPost,
(post) => {
persistedPublishKind.value = getPersistedPublishKind(post)
if (post?.publishedAt) {
persistedPublishedAtIso.value = post.publishedAt
}
},
{ deep: true }
)
const autosaveKey = computed(() => `${autosaveStoragePrefix}:${props.initialPost.id || 'new'}`)
const postUrlLabel = computed(() => form.slug || toSlug(form.title) || '')
const postUrlHint = computed(() => props.publicUrl || (postUrlLabel.value ? `/post/${postUrlLabel.value}/` : '/post/'))
const savedPostSlug = computed(() => toSlug(props.initialPost?.slug || ''))
/**
* 제목에서 파생한 슬러그(초안·미수동 편집 시 저장·표시에 사용)
* @returns {string}
*/
const titleDerivedSlug = computed(() => toSlug(form.title))
/**
* 제목 기반 자동 슬러그 표시 여부(직접 편집 전)
* @returns {boolean}
*/
const isSlugAutoFromTitle = computed(() => !slugTouched.value
&& persistedPublishKind.value === 'draft'
&& Boolean(titleDerivedSlug.value))
/**
* 저장·미리보기에 쓰는 최종 슬러그
* @returns {string}
*/
const effectiveSlug = computed(() => {
if (isSlugAutoFromTitle.value) {
return titleDerivedSlug.value
}
return toSlug(form.slug || form.title) || ''
})
const postUrlHint = computed(() => (effectiveSlug.value ? `/post/${effectiveSlug.value}/` : '/post/'))
const hasUnsavedSlugChange = computed(() => Boolean(
savedPostSlug.value
&& effectiveSlug.value
&& effectiveSlug.value !== savedPostSlug.value
))
/**
* Post URL 입력값(자동 슬러그는 제목에서 연동·연한 스타일)
*/
const slugInputValue = computed({
get: () => (isSlugAutoFromTitle.value ? titleDerivedSlug.value : form.slug),
set: (value) => {
slugTouched.value = true
form.slug = value
}
})
/** 저장된 공개 URL(View Post). 슬러그 미저장 변경 중에는 기존 URL 유지 */
const viewPostUrl = computed(() => {
if (!props.publicUrl) {
return ''
}
if (hasUnsavedSlugChange.value) {
return props.publicUrl
}
return effectiveSlug.value ? `/post/${effectiveSlug.value}` : props.publicUrl
})
/**
* 검색엔진 노출 여부(`noindex` 반전, 기본 노출)
@@ -221,22 +319,20 @@ const normalizeTagToken = (value) => {
.replace(/^-|-$/g, '')
}
watch(() => form.title, (title) => {
if (!slugTouched.value) {
const next = toSlug(title)
if (next) {
form.slug = next
}
}
})
/**
* 슬러그 직접 입력 상태 표시
* 슬러그 입력 포커스 아웃 시 정규화·자동 모드 복귀
* @returns {void}
*/
const touchSlug = () => {
slugTouched.value = true
const commitSlugInput = () => {
if (!slugTouched.value) {
return
}
form.slug = toSlug(form.slug)
if (!form.slug && persistedPublishKind.value === 'draft' && titleDerivedSlug.value) {
slugTouched.value = false
}
}
/**
@@ -282,43 +378,6 @@ const isDraftLike = computed(() => form.status === 'draft' && !isScheduledPost()
/** 발행됨(즉시 공개) */
const isPublishedLive = computed(() => form.status === 'published' && !isScheduledPost())
/**
* 서버에 반영된 게시 형태(툴바·자동 저장 분기 전용)
* @param {Object} post - 게시물
* @returns {'draft' | 'publishedLive' | 'scheduled'}
*/
const getPersistedPublishKind = (post) => {
if (!post?.id) {
return 'draft'
}
const st = post.status === 'private' ? 'draft' : post.status
if (st !== 'published') {
return 'draft'
}
const pa = post.publishedAt
if (pa && new Date(pa) > new Date()) {
return 'scheduled'
}
return 'publishedLive'
}
/** 마지막으로 서버와 맞춘 게시 형태 */
const persistedPublishKind = ref(getPersistedPublishKind(props.initialPost))
/** 서버에 반영된 발행 시각(본문 Update 시 유지) */
const persistedPublishedAtIso = ref(props.initialPost.publishedAt || null)
watch(
() => props.initialPost,
(post) => {
persistedPublishKind.value = getPersistedPublishKind(post)
if (post?.publishedAt) {
persistedPublishedAtIso.value = post.publishedAt
}
},
{ deep: true }
)
/** 툴바·상태줄에 즉시 발행으로 표시할지(서버 기준이거나 초안 글에서 폼만 발행으로 바뀐 경우) */
const displayPublishedLive = computed(() =>
persistedPublishKind.value === 'publishedLive'
@@ -409,7 +468,7 @@ const createPostPayload = (options = {}) => {
return {
title: toAdminPostStoredTitle(form.title),
slug: toSlug(form.slug || form.title),
slug: effectiveSlug.value,
excerpt: form.excerpt.trim(),
content: normalizeMarkdownContent(form.content),
featuredImage: form.featuredImage.trim() || null,
@@ -439,10 +498,7 @@ const serializedPostFingerprint = computed(() => serializePostPayload())
* 초안 자동 저장을 서버에 보낼 수 있는지(슬러그 유효, 제목은 비어 있으면 서버에서 `(제목 없음)`으로 보정)
* @returns {boolean}
*/
const canPersistDraftPayload = computed(() => {
const s = toSlug(form.slug || form.title)
return Boolean(s)
})
const canPersistDraftPayload = computed(() => Boolean(effectiveSlug.value))
/**
* 서버 자동 저장 대상: 폼이 초안이고, 서버에도 아직 초안으로만 반영된 글(발행·예약 글의 미저장 사이드바 변경은 제외)
@@ -698,6 +754,39 @@ const dropFeaturedImage = async (event) => {
await uploadFeaturedImageFile(files[0])
}
/**
* 날짜·시간 입력의 네이티브 선택 UI를 연다.
* @param {MouseEvent} event - 클릭 이벤트
* @returns {void}
*/
const openDateTimePicker = (event) => {
const label = event.currentTarget?.closest('label')
if (!label) {
return
}
const input = label.querySelector('input[type="date"], input[type="time"]')
if (!input || input.disabled) {
return
}
event.preventDefault()
if (typeof input.showPicker === 'function') {
try {
input.showPicker()
return
} catch {
// showPicker 미지원·사용자 제스처 제한 시 focus로 대체
}
}
input.focus()
input.click()
}
/**
* 제목 입력 후 본문 에디터로 이동
* @param {KeyboardEvent} event - 키보드 이벤트
@@ -1056,9 +1145,9 @@ defineExpose({
</NuxtLink>
<div class="admin-post-form__toolbar-status-row flex min-w-0 flex-1 items-center gap-2">
<NuxtLink
v-if="displayPublishedLive && canViewPost && publicUrl"
v-if="displayPublishedLive && canViewPost && viewPostUrl"
class="admin-post-form__toolbar-status-published inline-flex items-center gap-1 truncate rounded px-2 py-1.5 text-sm font-medium text-[#8E9CAC] transition-colors hover:bg-[#f1f3f4] hover:text-[#394047]"
:to="publicUrl"
:to="viewPostUrl"
target="_blank"
rel="noopener noreferrer"
>
@@ -1236,28 +1325,41 @@ defineExpose({
<div class="admin-post-form__post-url-header flex h-[22px] items-center justify-between">
<span class="admin-post-form__label font-bold text-[#15171a]">Post URL</span>
<NuxtLink
v-if="canViewPost"
v-if="canViewPost && viewPostUrl"
class="admin-post-form__view-post inline-flex items-center gap-1 rounded px-3 py-1.5 text-xs text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
:to="publicUrl"
:to="viewPostUrl"
target="_blank"
>
<span>View Post</span>
<span aria-hidden="true"></span>
</NuxtLink>
</div>
<label class="admin-post-form__post-url-input flex h-[38px] items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<label
class="admin-post-form__post-url-input flex h-[38px] items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]"
:class="{ 'admin-post-form__post-url-input--auto': isSlugAutoFromTitle }"
>
<span class="admin-post-form__post-url-icon text-sm text-[#394047]" aria-hidden="true"></span>
<input
v-model="form.slug"
class="admin-post-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none"
v-model="slugInputValue"
class="admin-post-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none"
:class="isSlugAutoFromTitle ? 'text-[#8e9cac]' : 'text-black'"
type="text"
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
required
@input="touchSlug"
:required="!isSlugAutoFromTitle"
:aria-describedby="isSlugAutoFromTitle ? 'admin-post-form-slug-auto-hint' : undefined"
@blur="commitSlugInput"
>
</label>
<p
v-if="isSlugAutoFromTitle"
id="admin-post-form-slug-auto-hint"
class="admin-post-form__post-url-auto-hint text-xs text-[#8e9cac]"
>
제목에서 자동 생성됩니다. 직접 수정하면 고정됩니다.
</p>
<p class="admin-post-form__post-url-hint text-xs text-[#7c8b9a]">
{{ postUrlHint }}
<span v-if="hasUnsavedSlugChange" class="admin-post-form__post-url-hint-unsaved text-[#8e9cac]"> (저장 반영)</span>
</p>
</div>
@@ -1279,11 +1381,17 @@ defineExpose({
class="admin-post-form__publish-date-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-9 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="date"
>
<span class="admin-post-form__publish-date-icon pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[#8e9cac]" aria-hidden="true">
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<button
class="admin-post-form__publish-date-icon absolute right-1 top-1/2 grid size-8 -translate-y-1/2 place-items-center rounded text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
type="button"
tabindex="-1"
aria-label="발행 날짜 선택"
@click="openDateTimePicker"
>
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 2v3M16 2v3M3 9h18M5 5h14a2 2 0 012 2v13a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2z" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" />
</svg>
</span>
</button>
</label>
<label class="admin-post-form__publish-time-field relative block">
<span class="sr-only">발행 시각</span>
@@ -1292,7 +1400,15 @@ defineExpose({
class="admin-post-form__publish-time-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-12 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="time"
>
<span class="admin-post-form__publish-time-zone pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-[#8e9cac]">KST</span>
<button
class="admin-post-form__publish-time-zone absolute right-1 top-1/2 grid h-8 min-w-[2.5rem] -translate-y-1/2 place-items-center rounded px-2 text-xs font-medium text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
type="button"
tabindex="-1"
aria-label="발행 시각 선택"
@click="openDateTimePicker"
>
KST
</button>
</label>
</div>
<span class="admin-post-form__hint text-xs text-muted">
@@ -1656,11 +1772,17 @@ defineExpose({
class="admin-post-form__publish-date-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-9 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="date"
>
<span class="admin-post-form__publish-date-icon pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[#8e9cac]" aria-hidden="true">
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<button
class="admin-post-form__publish-date-icon absolute right-1 top-1/2 grid size-8 -translate-y-1/2 place-items-center rounded text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
type="button"
tabindex="-1"
aria-label="예약 발행 날짜 선택"
@click="openDateTimePicker"
>
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 2v3M16 2v3M3 9h18M5 5h14a2 2 0 012 2v13a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2z" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" />
</svg>
</span>
</button>
</label>
<label class="admin-post-form__publish-time-field relative block">
<span class="sr-only">예약 발행 시각</span>
@@ -1669,7 +1791,15 @@ defineExpose({
class="admin-post-form__publish-time-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-12 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="time"
>
<span class="admin-post-form__publish-time-zone pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-[#8e9cac]">KST</span>
<button
class="admin-post-form__publish-time-zone absolute right-1 top-1/2 grid h-8 min-w-[2.5rem] -translate-y-1/2 place-items-center rounded px-2 text-xs font-medium text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
type="button"
tabindex="-1"
aria-label="예약 발행 시각 선택"
@click="openDateTimePicker"
>
KST
</button>
</label>
</div>
</div>
@@ -1745,14 +1875,23 @@ defineExpose({
</template>
<style scoped>
input[type='date']::-webkit-calendar-picker-indicator,
input[type='time']::-webkit-calendar-picker-indicator {
opacity: 0;
}
input[type='date'],
input[type='time'] {
.admin-post-form__publish-date-input,
.admin-post-form__publish-time-input {
position: relative;
-webkit-appearance: none;
appearance: none;
}
.admin-post-form__publish-date-input::-webkit-calendar-picker-indicator,
.admin-post-form__publish-time-input::-webkit-calendar-picker-indicator {
position: absolute;
top: 0;
right: 0;
width: 2.25rem;
height: 100%;
margin: 0;
padding: 0;
cursor: pointer;
opacity: 0;
}
</style>