v1.2.0: 관리자 글 목록·슬러그·예약 시각 UX 정리
발행일 기준 목록 정렬, 추천 필터·별 표시, 슬러그 자동/수동 구분, 예약 날짜·시간 클릭 영역 수정. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,10 +42,17 @@ const emit = defineEmits(['submit', 'preview', 'delete', 'autosave'])
|
|||||||
|
|
||||||
const autosaveStoragePrefix = 'SORI_ADMIN_POST_AUTOSAVE'
|
const autosaveStoragePrefix = 'SORI_ADMIN_POST_AUTOSAVE'
|
||||||
let draftPersistTimer = null
|
let draftPersistTimer = null
|
||||||
const slugTouched = ref(
|
const slugTouched = ref((() => {
|
||||||
Boolean(props.initialPost.slug)
|
const post = props.initialPost
|
||||||
&& !isAdminPostDraftPlaceholderSlug(props.initialPost.slug)
|
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)
|
const blockEditor = ref(null)
|
||||||
/** 본문 에디터 모드: write | preview */
|
/** 본문 에디터 모드: write | preview */
|
||||||
const editorMode = ref('write')
|
const editorMode = ref('write')
|
||||||
@@ -147,9 +154,100 @@ const form = reactive({
|
|||||||
tagsText: props.initialPost.tags?.join(', ') || ''
|
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 autosaveKey = computed(() => `${autosaveStoragePrefix}:${props.initialPost.id || 'new'}`)
|
||||||
const postUrlLabel = computed(() => form.slug || toSlug(form.title) || '')
|
const savedPostSlug = computed(() => toSlug(props.initialPost?.slug || ''))
|
||||||
const postUrlHint = computed(() => props.publicUrl || (postUrlLabel.value ? `/post/${postUrlLabel.value}/` : '/post/'))
|
|
||||||
|
/**
|
||||||
|
* 제목에서 파생한 슬러그(초안·미수동 편집 시 저장·표시에 사용)
|
||||||
|
* @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` 반전, 기본 노출)
|
* 검색엔진 노출 여부(`noindex` 반전, 기본 노출)
|
||||||
@@ -221,22 +319,20 @@ const normalizeTagToken = (value) => {
|
|||||||
.replace(/^-|-$/g, '')
|
.replace(/^-|-$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => form.title, (title) => {
|
|
||||||
if (!slugTouched.value) {
|
|
||||||
const next = toSlug(title)
|
|
||||||
if (next) {
|
|
||||||
form.slug = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 슬러그 직접 입력 상태 표시
|
* 슬러그 입력 포커스 아웃 시 정규화·자동 모드 복귀
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const touchSlug = () => {
|
const commitSlugInput = () => {
|
||||||
slugTouched.value = true
|
if (!slugTouched.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
form.slug = toSlug(form.slug)
|
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())
|
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(() =>
|
const displayPublishedLive = computed(() =>
|
||||||
persistedPublishKind.value === 'publishedLive'
|
persistedPublishKind.value === 'publishedLive'
|
||||||
@@ -409,7 +468,7 @@ const createPostPayload = (options = {}) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: toAdminPostStoredTitle(form.title),
|
title: toAdminPostStoredTitle(form.title),
|
||||||
slug: toSlug(form.slug || form.title),
|
slug: effectiveSlug.value,
|
||||||
excerpt: form.excerpt.trim(),
|
excerpt: form.excerpt.trim(),
|
||||||
content: normalizeMarkdownContent(form.content),
|
content: normalizeMarkdownContent(form.content),
|
||||||
featuredImage: form.featuredImage.trim() || null,
|
featuredImage: form.featuredImage.trim() || null,
|
||||||
@@ -439,10 +498,7 @@ const serializedPostFingerprint = computed(() => serializePostPayload())
|
|||||||
* 초안 자동 저장을 서버에 보낼 수 있는지(슬러그 유효, 제목은 비어 있으면 서버에서 `(제목 없음)`으로 보정)
|
* 초안 자동 저장을 서버에 보낼 수 있는지(슬러그 유효, 제목은 비어 있으면 서버에서 `(제목 없음)`으로 보정)
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
const canPersistDraftPayload = computed(() => {
|
const canPersistDraftPayload = computed(() => Boolean(effectiveSlug.value))
|
||||||
const s = toSlug(form.slug || form.title)
|
|
||||||
return Boolean(s)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서버 자동 저장 대상: 폼이 초안이고, 서버에도 아직 초안으로만 반영된 글(발행·예약 글의 미저장 사이드바 변경은 제외)
|
* 서버 자동 저장 대상: 폼이 초안이고, 서버에도 아직 초안으로만 반영된 글(발행·예약 글의 미저장 사이드바 변경은 제외)
|
||||||
@@ -698,6 +754,39 @@ const dropFeaturedImage = async (event) => {
|
|||||||
await uploadFeaturedImageFile(files[0])
|
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 - 키보드 이벤트
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
@@ -1056,9 +1145,9 @@ defineExpose({
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="admin-post-form__toolbar-status-row flex min-w-0 flex-1 items-center gap-2">
|
<div class="admin-post-form__toolbar-status-row flex min-w-0 flex-1 items-center gap-2">
|
||||||
<NuxtLink
|
<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]"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
@@ -1236,28 +1325,41 @@ defineExpose({
|
|||||||
<div class="admin-post-form__post-url-header flex h-[22px] items-center justify-between">
|
<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>
|
<span class="admin-post-form__label font-bold text-[#15171a]">Post URL</span>
|
||||||
<NuxtLink
|
<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]"
|
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"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<span>View Post</span>
|
<span>View Post</span>
|
||||||
<span aria-hidden="true">↗</span>
|
<span aria-hidden="true">↗</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</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>
|
<span class="admin-post-form__post-url-icon text-sm text-[#394047]" aria-hidden="true">⌘</span>
|
||||||
<input
|
<input
|
||||||
v-model="form.slug"
|
v-model="slugInputValue"
|
||||||
class="admin-post-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none"
|
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"
|
type="text"
|
||||||
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
||||||
required
|
:required="!isSlugAutoFromTitle"
|
||||||
@input="touchSlug"
|
:aria-describedby="isSlugAutoFromTitle ? 'admin-post-form-slug-auto-hint' : undefined"
|
||||||
|
@blur="commitSlugInput"
|
||||||
>
|
>
|
||||||
</label>
|
</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]">
|
<p class="admin-post-form__post-url-hint text-xs text-[#7c8b9a]">
|
||||||
{{ postUrlHint }}
|
{{ postUrlHint }}
|
||||||
|
<span v-if="hasUnsavedSlugChange" class="admin-post-form__post-url-hint-unsaved text-[#8e9cac]"> (저장 후 반영)</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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"
|
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"
|
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">
|
<button
|
||||||
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
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" />
|
<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>
|
</svg>
|
||||||
</span>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<label class="admin-post-form__publish-time-field relative block">
|
<label class="admin-post-form__publish-time-field relative block">
|
||||||
<span class="sr-only">발행 시각</span>
|
<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"
|
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"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<span class="admin-post-form__hint text-xs text-muted">
|
<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"
|
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"
|
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">
|
<button
|
||||||
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
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" />
|
<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>
|
</svg>
|
||||||
</span>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<label class="admin-post-form__publish-time-field relative block">
|
<label class="admin-post-form__publish-time-field relative block">
|
||||||
<span class="sr-only">예약 발행 시각</span>
|
<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"
|
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"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1745,14 +1875,23 @@ defineExpose({
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
input[type='date']::-webkit-calendar-picker-indicator,
|
.admin-post-form__publish-date-input,
|
||||||
input[type='time']::-webkit-calendar-picker-indicator {
|
.admin-post-form__publish-time-input {
|
||||||
opacity: 0;
|
position: relative;
|
||||||
}
|
|
||||||
|
|
||||||
input[type='date'],
|
|
||||||
input[type='time'] {
|
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
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>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.2.0
|
||||||
|
|
||||||
|
- 관리자 글 목록 정렬·개수·추천 필터·별 표시, 슬러그·예약 시각 UX를 정리했다.
|
||||||
|
|
||||||
## v1.1.19
|
## v1.1.19
|
||||||
|
|
||||||
- 관리자 글쓰기 헤더에 작성/미리보기 전환, Update 시 발행일 유지, 미디어 검색.
|
- 관리자 글쓰기 헤더에 작성/미리보기 전환, Update 시 발행일 유지, 미디어 검색.
|
||||||
|
|||||||
@@ -447,8 +447,10 @@ components/content/
|
|||||||
|
|
||||||
> 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
|
> 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
|
||||||
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
||||||
> 관리자 글 목록 상단은 좌측에 제목 블록, 우측에 상태·태그·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다.
|
> 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다.
|
||||||
|
> 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다.
|
||||||
> 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.
|
> 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.
|
||||||
|
> 관리자 글 목록 기본 정렬(최신순·오래된순)은 **발행일** 기준이며, `published_at`이 없는 초안 등은 **수정일**(`updated_at`)로 대체한다. API(`listAdminPosts`)와 화면 필터 정렬 모두 동일 규칙을 쓴다.
|
||||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||||
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||||
> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
|
> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.2.0
|
||||||
|
|
||||||
|
- 관리자 글 목록: 발행일 기준 정렬(`published_at` 우선, 없으면 `updated_at`), 총·추천·필터 표시 개수, 추천만 필터, 추천 글 별(★) 열.
|
||||||
|
- 관리자 글 슬러그: Post URL 미리보기 즉시 반영·저장 전 안내, 초안은 제목 연동 자동 슬러그(연한 표시), 발행·예약 글은 제목 변경 시 슬러그 고정(중복 409 예방).
|
||||||
|
- 예약·발행 시각: 달력·KST 클릭 영역 `showPicker` 연동.
|
||||||
|
- 패키지 버전 `1.2.0`으로 갱신.
|
||||||
|
|
||||||
## v1.1.19
|
## v1.1.19
|
||||||
|
|
||||||
- 관리자 글쓰기: 작성/미리보기 토글을 툴바에서 헤더(Update 왼쪽)로 이동, 미리보기 시 툴바 숨김, 미디어 모달 파일명 검색.
|
- 관리자 글쓰기: 작성/미리보기 토글을 툴바에서 헤더(Update 왼쪽)로 이동, 미리보기 시 툴바 숨김, 미디어 모달 파일명 검색.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.1.18",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.1.18",
|
"version": "1.2.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.1.19",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const deletingId = ref('')
|
|||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const statusFilter = ref('all')
|
const statusFilter = ref('all')
|
||||||
const tagFilter = ref('all')
|
const tagFilter = ref('all')
|
||||||
|
const featuredFilter = ref('all')
|
||||||
const sortOrder = ref('newest')
|
const sortOrder = ref('newest')
|
||||||
|
|
||||||
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
|
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
|
||||||
@@ -47,6 +48,17 @@ const getUpdatedDateLabel = (post) => {
|
|||||||
return `수정: ${formatPostDateTime(post.updatedAt)}`
|
return `수정: ${formatPostDateTime(post.updatedAt)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목록 정렬용 시각(ms). 발행일이 있으면 발행일, 없으면 수정일(초안 등).
|
||||||
|
* @param {Object} post - 게시물
|
||||||
|
* @returns {number} 정렬 기준 시각
|
||||||
|
*/
|
||||||
|
const getListSortTimestamp = (post) => {
|
||||||
|
const sortAt = post.publishedAt || post.updatedAt || post.createdAt
|
||||||
|
|
||||||
|
return new Date(sortAt || 0).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 공개 여부 확인
|
* 게시물 공개 여부 확인
|
||||||
* @param {Object} post - 게시물
|
* @param {Object} post - 게시물
|
||||||
@@ -131,17 +143,30 @@ const usedTagSlugs = computed(() => {
|
|||||||
return [...slugs].sort((a, b) => getTagName(a).localeCompare(getTagName(b), 'ko'))
|
return [...slugs].sort((a, b) => getTagName(a).localeCompare(getTagName(b), 'ko'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 전체 게시물 수 */
|
||||||
|
const totalPostCount = computed(() => posts.value.length)
|
||||||
|
|
||||||
|
/** 추천(`isFeatured`) 게시물 수 */
|
||||||
|
const featuredPostCount = computed(() => posts.value.filter((post) => post.isFeatured).length)
|
||||||
|
|
||||||
|
/** 필터가 적용됐는지 */
|
||||||
|
const hasActiveListFilters = computed(() => statusFilter.value !== 'all'
|
||||||
|
|| tagFilter.value !== 'all'
|
||||||
|
|| featuredFilter.value !== 'all')
|
||||||
|
|
||||||
const filteredPosts = computed(() => {
|
const filteredPosts = computed(() => {
|
||||||
const filtered = posts.value.filter((post) => {
|
const filtered = posts.value.filter((post) => {
|
||||||
const matchesStatus = statusFilter.value === 'all' || getPostStatusKey(post) === statusFilter.value
|
const matchesStatus = statusFilter.value === 'all' || getPostStatusKey(post) === statusFilter.value
|
||||||
const matchesTag = tagFilter.value === 'all' || (post.tags || []).includes(tagFilter.value)
|
const matchesTag = tagFilter.value === 'all' || (post.tags || []).includes(tagFilter.value)
|
||||||
|
const matchesFeatured = featuredFilter.value === 'all'
|
||||||
|
|| (featuredFilter.value === 'featured' && post.isFeatured)
|
||||||
|
|
||||||
return matchesStatus && matchesTag
|
return matchesStatus && matchesTag && matchesFeatured
|
||||||
})
|
})
|
||||||
|
|
||||||
return [...filtered].sort((a, b) => {
|
return [...filtered].sort((a, b) => {
|
||||||
const left = new Date(a.updatedAt || a.createdAt || 0).getTime()
|
const left = getListSortTimestamp(a)
|
||||||
const right = new Date(b.updatedAt || b.createdAt || 0).getTime()
|
const right = getListSortTimestamp(b)
|
||||||
|
|
||||||
return sortOrder.value === 'oldest' ? left - right : right - left
|
return sortOrder.value === 'oldest' ? left - right : right - left
|
||||||
})
|
})
|
||||||
@@ -183,6 +208,17 @@ const deletePost = async (post) => {
|
|||||||
<h1 class="admin-posts__title mt-2 text-3xl font-semibold">
|
<h1 class="admin-posts__title mt-2 text-3xl font-semibold">
|
||||||
글 목록
|
글 목록
|
||||||
</h1>
|
</h1>
|
||||||
|
<p class="admin-posts__count mt-2 text-sm text-muted">
|
||||||
|
총 {{ totalPostCount }}개
|
||||||
|
<template v-if="featuredPostCount > 0">
|
||||||
|
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
|
||||||
|
<span class="admin-posts__count-featured">추천 {{ featuredPostCount }}개</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="hasActiveListFilters && filteredPosts.length !== totalPostCount">
|
||||||
|
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
|
||||||
|
<span class="admin-posts__count-filtered">표시 {{ filteredPosts.length }}개</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-posts__header-actions flex min-w-0 flex-1 flex-wrap items-center justify-start gap-2 lg:flex-nowrap lg:justify-end">
|
<div class="admin-posts__header-actions flex min-w-0 flex-1 flex-wrap items-center justify-start gap-2 lg:flex-nowrap lg:justify-end">
|
||||||
<div class="admin-posts__filters flex min-w-0 flex-wrap items-center gap-2">
|
<div class="admin-posts__filters flex min-w-0 flex-wrap items-center gap-2">
|
||||||
@@ -204,6 +240,13 @@ const deletePost = async (post) => {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="admin-posts__filter">
|
||||||
|
<span class="sr-only">추천 필터</span>
|
||||||
|
<select v-model="featuredFilter" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
||||||
|
<option value="all">전체 글</option>
|
||||||
|
<option value="featured">추천만</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="admin-posts__filter">
|
<label class="admin-posts__filter">
|
||||||
<span class="sr-only">정렬</span>
|
<span class="sr-only">정렬</span>
|
||||||
<select v-model="sortOrder" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
<select v-model="sortOrder" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
||||||
@@ -226,6 +269,10 @@ const deletePost = async (post) => {
|
|||||||
<table class="admin-posts__table-inner w-full border-collapse text-left text-sm">
|
<table class="admin-posts__table-inner w-full border-collapse text-left text-sm">
|
||||||
<thead class="admin-posts__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
<thead class="admin-posts__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="admin-posts__cell admin-posts__cell-featured w-10 px-2 py-3 text-center">
|
||||||
|
<span class="sr-only">추천</span>
|
||||||
|
<span class="inline-flex size-4 items-center justify-center text-[#f5a623]" aria-hidden="true">★</span>
|
||||||
|
</th>
|
||||||
<th class="admin-posts__cell px-4 py-3">제목</th>
|
<th class="admin-posts__cell px-4 py-3">제목</th>
|
||||||
<th class="admin-posts__cell px-4 py-3">상태</th>
|
<th class="admin-posts__cell px-4 py-3">상태</th>
|
||||||
<th class="admin-posts__cell px-4 py-3">태그</th>
|
<th class="admin-posts__cell px-4 py-3">태그</th>
|
||||||
@@ -235,6 +282,18 @@ const deletePost = async (post) => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
|
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
|
||||||
<tr v-for="post in filteredPosts" :key="post.id" class="admin-posts__row">
|
<tr v-for="post in filteredPosts" :key="post.id" class="admin-posts__row">
|
||||||
|
<td class="admin-posts__cell admin-posts__cell-featured w-10 px-2 py-4 text-center">
|
||||||
|
<span
|
||||||
|
v-if="post.isFeatured"
|
||||||
|
class="admin-posts__featured-star inline-flex size-8 items-center justify-center text-[#f5a623]"
|
||||||
|
title="추천 글"
|
||||||
|
aria-label="추천 글"
|
||||||
|
>
|
||||||
|
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td class="admin-posts__cell px-4 py-4">
|
<td class="admin-posts__cell px-4 py-4">
|
||||||
<div class="admin-posts__title-row flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div class="admin-posts__title-row flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
|
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export const listAdminPosts = async () => {
|
|||||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||||
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
||||||
GROUP BY posts.id
|
GROUP BY posts.id
|
||||||
ORDER BY posts.updated_at DESC
|
ORDER BY COALESCE(posts.published_at, posts.updated_at) DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows.map(mapAdminPostRow)
|
return rows.map(mapAdminPostRow)
|
||||||
|
|||||||
Reference in New Issue
Block a user