게시물 발행 상태 로직 분리
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
toAdminPostFormTitle,
|
||||
toAdminPostStoredTitle
|
||||
} from '../../lib/admin-post-title.js'
|
||||
import { toAdminPostDateTimeLocalValue } from '../../composables/useAdminPostPublishState.js'
|
||||
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
||||
import { DEFAULT_POST_TAG_LIMIT } from '../../lib/post-tag-limit.js'
|
||||
|
||||
@@ -64,12 +65,6 @@ const isTitleInputComposing = ref(false)
|
||||
const savedPostSnapshot = ref('')
|
||||
const failedDraftAutosaveSnapshot = ref('')
|
||||
const slugFieldError = ref('')
|
||||
const isPublishModalOpen = ref(false)
|
||||
const isUnpublishModalOpen = ref(false)
|
||||
const publishStatus = ref('draft')
|
||||
const publishTiming = ref('now')
|
||||
const scheduledPublishAt = ref('')
|
||||
const publishModalExpandedSection = ref(null)
|
||||
|
||||
const { data: adminTags } = useFetch('/admin/api/tags', {
|
||||
default: () => []
|
||||
@@ -81,72 +76,6 @@ const { data: siteSettings } = useFetch('/admin/api/settings', {
|
||||
/** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */
|
||||
const READING_CHARS_PER_MINUTE = 600
|
||||
|
||||
/**
|
||||
* ISO 날짜를 datetime-local 입력값으로 변환
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {string} datetime-local 입력값
|
||||
*/
|
||||
function toDateTimeLocalValue(value) {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const offsetDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
||||
|
||||
return offsetDate.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* datetime-local 입력값을 ISO 문자열로 변환
|
||||
* @param {string} value - datetime-local 입력값
|
||||
* @returns {string | null} ISO 날짜 문자열
|
||||
*/
|
||||
function toIsoDateTime(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* datetime-local 값을 날짜·시간 필드로 분리
|
||||
* @param {string} value - datetime-local 문자열
|
||||
* @returns {{ date: string, time: string }}
|
||||
*/
|
||||
function splitDateTimeLocal(value) {
|
||||
if (!value || !value.includes('T')) {
|
||||
return { date: '', time: '' }
|
||||
}
|
||||
const [date, time] = value.split('T')
|
||||
return { date: date || '', time: (time || '').slice(0, 5) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜·시간 필드를 datetime-local 문자열로 합침
|
||||
* @param {string} date - YYYY-MM-DD
|
||||
* @param {string} time - HH:mm
|
||||
* @returns {string}
|
||||
*/
|
||||
function joinDateTimeLocal(date, time) {
|
||||
if (!date) {
|
||||
return ''
|
||||
}
|
||||
return `${date}T${time || '00:00'}`
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
title: toAdminPostFormTitle(props.initialPost.title),
|
||||
slug: props.initialPost.slug || '',
|
||||
@@ -157,10 +86,49 @@ const form = reactive({
|
||||
isFeatured: Boolean(props.initialPost.isFeatured),
|
||||
noindex: props.initialPost.noindex === true,
|
||||
status: props.initialPost.status || 'draft',
|
||||
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
|
||||
publishedAt: toAdminPostDateTimeLocalValue(props.initialPost.publishedAt),
|
||||
tagsText: props.initialPost.tags?.join(', ') || ''
|
||||
})
|
||||
|
||||
const initialPostRef = computed(() => props.initialPost)
|
||||
|
||||
const {
|
||||
applyPublishSelectionToForm,
|
||||
closePublishModal,
|
||||
closeUnpublishModal,
|
||||
displayMembersOnly,
|
||||
displayPrivatePost,
|
||||
displayPublishedLive,
|
||||
displayScheduled,
|
||||
isDraftLike,
|
||||
isPublishModalOpen,
|
||||
isScheduledPost,
|
||||
isUnpublishModalOpen,
|
||||
markPublishStateSaved,
|
||||
modalScheduleDate,
|
||||
modalScheduleTime,
|
||||
openPublishModal: openPublishModalState,
|
||||
openUnpublishModal,
|
||||
persistedPublishKind,
|
||||
publishModalExpandedSection,
|
||||
publishStatus,
|
||||
publishStatusSummaryLabel,
|
||||
publishTiming,
|
||||
publishTimingSummaryLabel,
|
||||
resolvePayloadPublishedAt,
|
||||
scheduledStatusTooltip,
|
||||
selectPublishStatus,
|
||||
selectPublishTiming,
|
||||
settingsPublishDate,
|
||||
settingsPublishTime,
|
||||
togglePublishModalSection,
|
||||
unpublishModalKind,
|
||||
unpublishPersistedPublishedLabel
|
||||
} = useAdminPostPublishState({
|
||||
form,
|
||||
initialPost: initialPostRef
|
||||
})
|
||||
|
||||
/**
|
||||
* 숫자를 한국어 로케일 문자열로 변환한다.
|
||||
* @param {number} value - 숫자 값
|
||||
@@ -239,46 +207,6 @@ const postWritingStats = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 서버에 반영된 게시 형태(툴바·자동 저장·슬러그 자동 연동 분기)
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {'draft' | 'publishedLive' | 'scheduled' | 'members' | 'private'}
|
||||
*/
|
||||
const getPersistedPublishKind = (post) => {
|
||||
if (!post?.id) {
|
||||
return 'draft'
|
||||
}
|
||||
const st = post.status || 'draft'
|
||||
if (st === 'members' || st === 'private') {
|
||||
return st
|
||||
}
|
||||
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 savedPostSlug = computed(() => toSlug(props.initialPost?.slug || ''))
|
||||
|
||||
@@ -460,112 +388,6 @@ const {
|
||||
form
|
||||
})
|
||||
|
||||
/**
|
||||
* 예약 발행 여부 확인
|
||||
* @returns {boolean} 예약 발행 여부
|
||||
*/
|
||||
const isScheduledPost = () => {
|
||||
const publishedAt = toIsoDateTime(form.publishedAt)
|
||||
|
||||
return form.status === 'published' && Boolean(publishedAt) && new Date(publishedAt) > new Date()
|
||||
}
|
||||
|
||||
/** 초안 편집 UI(예약 발행 제외) */
|
||||
const isDraftLike = computed(() => form.status === 'draft' && !isScheduledPost())
|
||||
|
||||
/** 발행됨(즉시 공개) */
|
||||
const isPublishedLive = computed(() => form.status === 'published' && !isScheduledPost())
|
||||
|
||||
/** 툴바·상태줄에 즉시 발행으로 표시할지(서버 기준이거나 초안 글에서 폼만 발행으로 바뀐 경우) */
|
||||
const displayPublishedLive = computed(() =>
|
||||
persistedPublishKind.value === 'publishedLive'
|
||||
|| (persistedPublishKind.value === 'draft' && isPublishedLive.value))
|
||||
|
||||
/** 툴바·상태줄에 예약으로 표시할지 */
|
||||
const displayScheduled = computed(() =>
|
||||
persistedPublishKind.value === 'scheduled'
|
||||
|| (persistedPublishKind.value === 'draft' && isScheduledPost()))
|
||||
|
||||
/** 툴바·상태줄에 멤버십 글로 표시할지 */
|
||||
const displayMembersOnly = computed(() =>
|
||||
persistedPublishKind.value === 'members'
|
||||
|| (persistedPublishKind.value === 'draft' && form.status === 'members'))
|
||||
|
||||
/** 툴바·상태줄에 비공개 글로 표시할지 */
|
||||
const displayPrivatePost = computed(() =>
|
||||
persistedPublishKind.value === 'private'
|
||||
|| (persistedPublishKind.value === 'draft' && form.status === 'private'))
|
||||
|
||||
/**
|
||||
* 발행 모달에 표시할 게시 상태 요약 문구
|
||||
* @returns {string} 요약 문구
|
||||
*/
|
||||
const publishStatusSummaryLabel = computed(() => {
|
||||
if (publishStatus.value === 'published') {
|
||||
return '발행'
|
||||
}
|
||||
|
||||
return '초안'
|
||||
})
|
||||
|
||||
/**
|
||||
* 발행 모달에 표시할 발행 시점 요약 문구
|
||||
* @returns {string} 요약 문구
|
||||
*/
|
||||
const publishTimingSummaryLabel = computed(() => {
|
||||
if (publishTiming.value === 'now') {
|
||||
return '지금 바로'
|
||||
}
|
||||
|
||||
const raw = scheduledPublishAt.value
|
||||
|
||||
if (!raw) {
|
||||
return '예약'
|
||||
}
|
||||
|
||||
const date = new Date(raw)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '예약'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date)
|
||||
})
|
||||
|
||||
/**
|
||||
* 저장 요청용 발행 시각을 결정한다.
|
||||
* @param {{ allowNewPublishTimestamp?: boolean }} [options] - 신규 발행 시각 허용 여부
|
||||
* @returns {string|null} ISO 발행 시각 또는 null
|
||||
*/
|
||||
const resolvePayloadPublishedAt = (options = {}) => {
|
||||
if (form.status !== 'published') {
|
||||
return null
|
||||
}
|
||||
|
||||
const fromForm = toIsoDateTime(form.publishedAt)
|
||||
|
||||
if (fromForm) {
|
||||
return fromForm
|
||||
}
|
||||
|
||||
if (persistedPublishedAtIso.value) {
|
||||
return persistedPublishedAtIso.value
|
||||
}
|
||||
|
||||
if (props.initialPost.publishedAt) {
|
||||
return props.initialPost.publishedAt
|
||||
}
|
||||
|
||||
if (options.allowNewPublishTimestamp) {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 입력값 생성
|
||||
* @param {{ allowNewPublishTimestamp?: boolean }} [options] - 신규 발행 시각 허용 여부
|
||||
@@ -673,26 +495,6 @@ const headerDraftStatusText = computed(() => {
|
||||
return props.initialPost?.id ? 'Draft - Saved' : 'Draft'
|
||||
})
|
||||
|
||||
/**
|
||||
* 예약 발행일 툴팁(영문 한 줄)
|
||||
* @returns {string}
|
||||
*/
|
||||
const scheduledStatusTooltip = computed(() => {
|
||||
const iso = toIsoDateTime(form.publishedAt)
|
||||
if (!iso) {
|
||||
return ''
|
||||
}
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const line = d.toLocaleString('en-GB', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short'
|
||||
})
|
||||
return `Scheduled to be published on ${line}`
|
||||
})
|
||||
|
||||
/**
|
||||
* 레거시 로컬 자동 저장 키 제거
|
||||
* @returns {void}
|
||||
@@ -910,22 +712,6 @@ const requestToolbarUpdate = () => {
|
||||
emit('submit', createPostPayload())
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 취소·예약 취소 확인 화면을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openUnpublishModal = () => {
|
||||
isUnpublishModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 취소·예약 취소 확인 화면을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeUnpublishModal = () => {
|
||||
isUnpublishModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 취소 또는 예약 취소(초안으로 되돌림)를 확정한다.
|
||||
* @returns {void}
|
||||
@@ -960,62 +746,12 @@ const toggleSettingsPanel = () => {
|
||||
isSettingsOpen.value = !isSettingsOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달을 현재 폼 상태로 초기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncPublishModalStateFromForm = () => {
|
||||
if (persistedPublishKind.value === 'draft') {
|
||||
publishStatus.value = 'published'
|
||||
publishTiming.value = 'now'
|
||||
scheduledPublishAt.value = toDateTimeLocalValue(new Date().toISOString())
|
||||
return
|
||||
}
|
||||
|
||||
publishStatus.value = form.status === 'published' ? 'published' : 'draft'
|
||||
scheduledPublishAt.value = form.publishedAt || toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
|
||||
publishTiming.value = isScheduledPost() ? 'schedule' : 'now'
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달 열기
|
||||
* @returns {void}
|
||||
*/
|
||||
/** 발행 모달을 연다. */
|
||||
const openPublishModal = () => {
|
||||
if (hasBlockingSlugError.value) {
|
||||
return
|
||||
}
|
||||
syncPublishModalStateFromForm()
|
||||
publishModalExpandedSection.value = null
|
||||
isPublishModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달 닫기
|
||||
* @returns {void}
|
||||
*/
|
||||
const closePublishModal = () => {
|
||||
isPublishModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 선택한 값을 폼에 반영
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyPublishSelectionToForm = () => {
|
||||
form.status = publishStatus.value
|
||||
|
||||
if (publishStatus.value !== 'published') {
|
||||
form.publishedAt = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (publishTiming.value === 'schedule') {
|
||||
form.publishedAt = scheduledPublishAt.value || toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
|
||||
return
|
||||
}
|
||||
|
||||
form.publishedAt = toDateTimeLocalValue(new Date().toISOString())
|
||||
openPublishModalState()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1030,104 +766,6 @@ const submitFromPublishModal = () => {
|
||||
submitPost()
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 설정 행 펼침을 토글한다.
|
||||
* @param {'status' | 'timing'} section - 펼칠 행
|
||||
* @returns {void}
|
||||
*/
|
||||
const togglePublishModalSection = (section) => {
|
||||
publishModalExpandedSection.value =
|
||||
publishModalExpandedSection.value === section ? null : section
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 게시 상태를 선택한다.
|
||||
* @param {'published' | 'draft'} status - 선택 상태
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectPublishStatus = (status) => {
|
||||
publishStatus.value = status
|
||||
publishModalExpandedSection.value = null
|
||||
|
||||
if (status !== 'published') {
|
||||
publishTiming.value = 'now'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 발행 시점을 선택한다.
|
||||
* @param {'now' | 'schedule'} timing - 즉시 또는 예약
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectPublishTiming = (timing) => {
|
||||
publishTiming.value = timing
|
||||
|
||||
if (timing === 'now') {
|
||||
publishModalExpandedSection.value = null
|
||||
} else if (!scheduledPublishAt.value) {
|
||||
scheduledPublishAt.value = toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
|
||||
}
|
||||
}
|
||||
|
||||
/** 설정 패널 발행 날짜(YYYY-MM-DD) */
|
||||
const settingsPublishDate = computed({
|
||||
get: () => splitDateTimeLocal(form.publishedAt).date,
|
||||
set: (date) => {
|
||||
const { time } = splitDateTimeLocal(form.publishedAt)
|
||||
form.publishedAt = joinDateTimeLocal(date, time || '12:00')
|
||||
}
|
||||
})
|
||||
|
||||
/** 설정 패널 발행 시각(HH:mm) */
|
||||
const settingsPublishTime = computed({
|
||||
get: () => splitDateTimeLocal(form.publishedAt).time,
|
||||
set: (time) => {
|
||||
const { date } = splitDateTimeLocal(form.publishedAt)
|
||||
const baseDate = date || new Date().toISOString().slice(0, 10)
|
||||
form.publishedAt = joinDateTimeLocal(baseDate, time || '12:00')
|
||||
}
|
||||
})
|
||||
|
||||
/** 발행 모달 예약 날짜 */
|
||||
const modalScheduleDate = computed({
|
||||
get: () => splitDateTimeLocal(scheduledPublishAt.value).date,
|
||||
set: (date) => {
|
||||
const { time } = splitDateTimeLocal(scheduledPublishAt.value)
|
||||
scheduledPublishAt.value = joinDateTimeLocal(date, time || '12:00')
|
||||
}
|
||||
})
|
||||
|
||||
/** 발행 모달 예약 시각 */
|
||||
const modalScheduleTime = computed({
|
||||
get: () => splitDateTimeLocal(scheduledPublishAt.value).time,
|
||||
set: (time) => {
|
||||
const { date } = splitDateTimeLocal(scheduledPublishAt.value)
|
||||
const baseDate = date || new Date().toISOString().slice(0, 10)
|
||||
scheduledPublishAt.value = joinDateTimeLocal(baseDate, time || '12:00')
|
||||
}
|
||||
})
|
||||
|
||||
/** 발행 취소 화면에 표시할 서버 반영 발행 시각 문구 */
|
||||
const unpublishPersistedPublishedLabel = computed(() => {
|
||||
const iso = props.initialPost?.publishedAt
|
||||
if (!iso) {
|
||||
return ''
|
||||
}
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return ''
|
||||
}
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short'
|
||||
}).format(d)
|
||||
})
|
||||
|
||||
/** 발행 취소 화면 제목(즉시 발행 / 예약) */
|
||||
const unpublishModalKind = computed(() => (
|
||||
persistedPublishKind.value === 'scheduled' ? 'scheduled' : 'published'
|
||||
))
|
||||
|
||||
/**
|
||||
* 현재 입력값을 저장 완료 기준점으로 표시한다.
|
||||
* @param {Object|null} [savedPayload] - 실제 서버에 저장된 입력값
|
||||
@@ -1138,10 +776,7 @@ const markSaved = (savedPayload = null) => {
|
||||
failedDraftAutosaveSnapshot.value = ''
|
||||
slugFieldError.value = ''
|
||||
const publishedAt = resolvePayloadPublishedAt()
|
||||
|
||||
if (publishedAt) {
|
||||
persistedPublishedAtIso.value = publishedAt
|
||||
}
|
||||
markPublishStateSaved(publishedAt)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1190,22 +825,6 @@ const takePendingAutosavePayload = () => {
|
||||
return createPostPayload()
|
||||
}
|
||||
|
||||
watch(publishStatus, (next) => {
|
||||
if (next !== 'published') {
|
||||
publishTiming.value = 'now'
|
||||
|
||||
if (publishModalExpandedSection.value === 'timing') {
|
||||
publishModalExpandedSection.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => form.status, (next) => {
|
||||
if (next === 'published' && !toIsoDateTime(form.publishedAt)) {
|
||||
form.publishedAt = toDateTimeLocalValue(new Date().toISOString())
|
||||
}
|
||||
})
|
||||
|
||||
watch(serializedPostFingerprint, () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
|
||||
450
composables/useAdminPostPublishState.js
Normal file
450
composables/useAdminPostPublishState.js
Normal file
@@ -0,0 +1,450 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* ISO 날짜를 datetime-local 입력값으로 변환한다.
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {string} datetime-local 입력값
|
||||
*/
|
||||
export const toAdminPostDateTimeLocalValue = (value) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const offsetDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
||||
|
||||
return offsetDate.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* datetime-local 입력값을 ISO 문자열로 변환한다.
|
||||
* @param {string} value - datetime-local 입력값
|
||||
* @returns {string | null} ISO 날짜 문자열
|
||||
*/
|
||||
const toIsoDateTime = (value) => {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* datetime-local 값을 날짜·시간 필드로 분리한다.
|
||||
* @param {string} value - datetime-local 문자열
|
||||
* @returns {{ date: string, time: string }}
|
||||
*/
|
||||
const splitDateTimeLocal = (value) => {
|
||||
if (!value || !value.includes('T')) {
|
||||
return { date: '', time: '' }
|
||||
}
|
||||
const [date, time] = value.split('T')
|
||||
return { date: date || '', time: (time || '').slice(0, 5) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜·시간 필드를 datetime-local 문자열로 합친다.
|
||||
* @param {string} date - YYYY-MM-DD
|
||||
* @param {string} time - HH:mm
|
||||
* @returns {string}
|
||||
*/
|
||||
const joinDateTimeLocal = (date, time) => {
|
||||
if (!date) {
|
||||
return ''
|
||||
}
|
||||
return `${date}T${time || '00:00'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버에 반영된 게시 형태를 계산한다.
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {'draft' | 'publishedLive' | 'scheduled' | 'members' | 'private'} 게시 형태
|
||||
*/
|
||||
const getPersistedPublishKind = (post) => {
|
||||
if (!post?.id) {
|
||||
return 'draft'
|
||||
}
|
||||
const st = post.status || 'draft'
|
||||
if (st === 'members' || st === 'private') {
|
||||
return st
|
||||
}
|
||||
if (st !== 'published') {
|
||||
return 'draft'
|
||||
}
|
||||
const pa = post.publishedAt
|
||||
if (pa && new Date(pa) > new Date()) {
|
||||
return 'scheduled'
|
||||
}
|
||||
return 'publishedLive'
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 게시물 폼의 발행·예약·발행 취소 상태를 만든다.
|
||||
* @param {Object} options - 발행 상태 옵션
|
||||
* @param {Object} options.form - 게시물 폼 reactive 객체
|
||||
* @param {import('vue').ComputedRef<Object>} options.initialPost - 서버 게시물 입력값
|
||||
* @returns {Object} 발행 상태와 이벤트 처리기
|
||||
*/
|
||||
export const useAdminPostPublishState = ({
|
||||
form,
|
||||
initialPost
|
||||
}) => {
|
||||
const getInitialPost = () => initialPost.value || {}
|
||||
const persistedPublishKind = ref(getPersistedPublishKind(getInitialPost()))
|
||||
const persistedPublishedAtIso = ref(getInitialPost().publishedAt || null)
|
||||
const isPublishModalOpen = ref(false)
|
||||
const isUnpublishModalOpen = ref(false)
|
||||
const publishStatus = ref('draft')
|
||||
const publishTiming = ref('now')
|
||||
const scheduledPublishAt = ref('')
|
||||
const publishModalExpandedSection = ref(null)
|
||||
|
||||
watch(
|
||||
initialPost,
|
||||
(post) => {
|
||||
persistedPublishKind.value = getPersistedPublishKind(post)
|
||||
if (post?.publishedAt) {
|
||||
persistedPublishedAtIso.value = post.publishedAt
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
/**
|
||||
* 예약 발행 여부를 확인한다.
|
||||
* @returns {boolean} 예약 발행 여부
|
||||
*/
|
||||
const isScheduledPost = () => {
|
||||
const publishedAt = toIsoDateTime(form.publishedAt)
|
||||
|
||||
return form.status === 'published' && Boolean(publishedAt) && new Date(publishedAt) > new Date()
|
||||
}
|
||||
|
||||
const isDraftLike = computed(() => form.status === 'draft' && !isScheduledPost())
|
||||
const isPublishedLive = computed(() => form.status === 'published' && !isScheduledPost())
|
||||
const displayPublishedLive = computed(() =>
|
||||
persistedPublishKind.value === 'publishedLive'
|
||||
|| (persistedPublishKind.value === 'draft' && isPublishedLive.value))
|
||||
const displayScheduled = computed(() =>
|
||||
persistedPublishKind.value === 'scheduled'
|
||||
|| (persistedPublishKind.value === 'draft' && isScheduledPost()))
|
||||
const displayMembersOnly = computed(() =>
|
||||
persistedPublishKind.value === 'members'
|
||||
|| (persistedPublishKind.value === 'draft' && form.status === 'members'))
|
||||
const displayPrivatePost = computed(() =>
|
||||
persistedPublishKind.value === 'private'
|
||||
|| (persistedPublishKind.value === 'draft' && form.status === 'private'))
|
||||
|
||||
const publishStatusSummaryLabel = computed(() => {
|
||||
if (publishStatus.value === 'published') {
|
||||
return '발행'
|
||||
}
|
||||
|
||||
return '초안'
|
||||
})
|
||||
|
||||
const publishTimingSummaryLabel = computed(() => {
|
||||
if (publishTiming.value === 'now') {
|
||||
return '지금 바로'
|
||||
}
|
||||
|
||||
const raw = scheduledPublishAt.value
|
||||
|
||||
if (!raw) {
|
||||
return '예약'
|
||||
}
|
||||
|
||||
const date = new Date(raw)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '예약'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date)
|
||||
})
|
||||
|
||||
/**
|
||||
* 저장 요청용 발행 시각을 결정한다.
|
||||
* @param {{ allowNewPublishTimestamp?: boolean }} [options] - 신규 발행 시각 허용 여부
|
||||
* @returns {string|null} ISO 발행 시각 또는 null
|
||||
*/
|
||||
const resolvePayloadPublishedAt = (options = {}) => {
|
||||
if (form.status !== 'published') {
|
||||
return null
|
||||
}
|
||||
|
||||
const fromForm = toIsoDateTime(form.publishedAt)
|
||||
|
||||
if (fromForm) {
|
||||
return fromForm
|
||||
}
|
||||
|
||||
if (persistedPublishedAtIso.value) {
|
||||
return persistedPublishedAtIso.value
|
||||
}
|
||||
|
||||
if (getInitialPost().publishedAt) {
|
||||
return getInitialPost().publishedAt
|
||||
}
|
||||
|
||||
if (options.allowNewPublishTimestamp) {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const scheduledStatusTooltip = computed(() => {
|
||||
const iso = toIsoDateTime(form.publishedAt)
|
||||
if (!iso) {
|
||||
return ''
|
||||
}
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const line = d.toLocaleString('en-GB', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short'
|
||||
})
|
||||
return `Scheduled to be published on ${line}`
|
||||
})
|
||||
|
||||
/**
|
||||
* 발행 모달을 현재 폼 상태로 초기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncPublishModalStateFromForm = () => {
|
||||
if (persistedPublishKind.value === 'draft') {
|
||||
publishStatus.value = 'published'
|
||||
publishTiming.value = 'now'
|
||||
scheduledPublishAt.value = toAdminPostDateTimeLocalValue(new Date().toISOString())
|
||||
return
|
||||
}
|
||||
|
||||
publishStatus.value = form.status === 'published' ? 'published' : 'draft'
|
||||
scheduledPublishAt.value = form.publishedAt || toAdminPostDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
|
||||
publishTiming.value = isScheduledPost() ? 'schedule' : 'now'
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openPublishModal = () => {
|
||||
syncPublishModalStateFromForm()
|
||||
publishModalExpandedSection.value = null
|
||||
isPublishModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closePublishModal = () => {
|
||||
isPublishModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 취소·예약 취소 확인 화면을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openUnpublishModal = () => {
|
||||
isUnpublishModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 취소·예약 취소 확인 화면을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeUnpublishModal = () => {
|
||||
isUnpublishModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 선택한 값을 폼에 반영한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyPublishSelectionToForm = () => {
|
||||
form.status = publishStatus.value
|
||||
|
||||
if (publishStatus.value !== 'published') {
|
||||
form.publishedAt = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (publishTiming.value === 'schedule') {
|
||||
form.publishedAt = scheduledPublishAt.value || toAdminPostDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
|
||||
return
|
||||
}
|
||||
|
||||
form.publishedAt = toAdminPostDateTimeLocalValue(new Date().toISOString())
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 설정 행 펼침을 토글한다.
|
||||
* @param {'status' | 'timing'} section - 펼칠 행
|
||||
* @returns {void}
|
||||
*/
|
||||
const togglePublishModalSection = (section) => {
|
||||
publishModalExpandedSection.value =
|
||||
publishModalExpandedSection.value === section ? null : section
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 게시 상태를 선택한다.
|
||||
* @param {'published' | 'draft'} status - 선택 상태
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectPublishStatus = (status) => {
|
||||
publishStatus.value = status
|
||||
publishModalExpandedSection.value = null
|
||||
|
||||
if (status !== 'published') {
|
||||
publishTiming.value = 'now'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 발행 시점을 선택한다.
|
||||
* @param {'now' | 'schedule'} timing - 즉시 또는 예약
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectPublishTiming = (timing) => {
|
||||
publishTiming.value = timing
|
||||
|
||||
if (timing === 'now') {
|
||||
publishModalExpandedSection.value = null
|
||||
} else if (!scheduledPublishAt.value) {
|
||||
scheduledPublishAt.value = toAdminPostDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
|
||||
}
|
||||
}
|
||||
|
||||
const settingsPublishDate = computed({
|
||||
get: () => splitDateTimeLocal(form.publishedAt).date,
|
||||
set: (date) => {
|
||||
const { time } = splitDateTimeLocal(form.publishedAt)
|
||||
form.publishedAt = joinDateTimeLocal(date, time || '12:00')
|
||||
}
|
||||
})
|
||||
|
||||
const settingsPublishTime = computed({
|
||||
get: () => splitDateTimeLocal(form.publishedAt).time,
|
||||
set: (time) => {
|
||||
const { date } = splitDateTimeLocal(form.publishedAt)
|
||||
const baseDate = date || new Date().toISOString().slice(0, 10)
|
||||
form.publishedAt = joinDateTimeLocal(baseDate, time || '12:00')
|
||||
}
|
||||
})
|
||||
|
||||
const modalScheduleDate = computed({
|
||||
get: () => splitDateTimeLocal(scheduledPublishAt.value).date,
|
||||
set: (date) => {
|
||||
const { time } = splitDateTimeLocal(scheduledPublishAt.value)
|
||||
scheduledPublishAt.value = joinDateTimeLocal(date, time || '12:00')
|
||||
}
|
||||
})
|
||||
|
||||
const modalScheduleTime = computed({
|
||||
get: () => splitDateTimeLocal(scheduledPublishAt.value).time,
|
||||
set: (time) => {
|
||||
const { date } = splitDateTimeLocal(scheduledPublishAt.value)
|
||||
const baseDate = date || new Date().toISOString().slice(0, 10)
|
||||
scheduledPublishAt.value = joinDateTimeLocal(baseDate, time || '12:00')
|
||||
}
|
||||
})
|
||||
|
||||
const unpublishPersistedPublishedLabel = computed(() => {
|
||||
const iso = getInitialPost().publishedAt
|
||||
if (!iso) {
|
||||
return ''
|
||||
}
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return ''
|
||||
}
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short'
|
||||
}).format(d)
|
||||
})
|
||||
|
||||
const unpublishModalKind = computed(() => (
|
||||
persistedPublishKind.value === 'scheduled' ? 'scheduled' : 'published'
|
||||
))
|
||||
|
||||
/**
|
||||
* 저장 성공 후 발행 기준 시각을 갱신한다.
|
||||
* @param {string|null} publishedAt - 저장된 발행 시각
|
||||
* @returns {void}
|
||||
*/
|
||||
const markPublishStateSaved = (publishedAt) => {
|
||||
if (publishedAt) {
|
||||
persistedPublishedAtIso.value = publishedAt
|
||||
}
|
||||
}
|
||||
|
||||
watch(publishStatus, (next) => {
|
||||
if (next !== 'published') {
|
||||
publishTiming.value = 'now'
|
||||
|
||||
if (publishModalExpandedSection.value === 'timing') {
|
||||
publishModalExpandedSection.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => form.status, (next) => {
|
||||
if (next === 'published' && !toIsoDateTime(form.publishedAt)) {
|
||||
form.publishedAt = toAdminPostDateTimeLocalValue(new Date().toISOString())
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
applyPublishSelectionToForm,
|
||||
closePublishModal,
|
||||
closeUnpublishModal,
|
||||
displayMembersOnly,
|
||||
displayPrivatePost,
|
||||
displayPublishedLive,
|
||||
displayScheduled,
|
||||
isDraftLike,
|
||||
isPublishModalOpen,
|
||||
isScheduledPost,
|
||||
isUnpublishModalOpen,
|
||||
markPublishStateSaved,
|
||||
modalScheduleDate,
|
||||
modalScheduleTime,
|
||||
openPublishModal,
|
||||
openUnpublishModal,
|
||||
persistedPublishKind,
|
||||
publishModalExpandedSection,
|
||||
publishStatus,
|
||||
publishStatusSummaryLabel,
|
||||
publishTiming,
|
||||
publishTimingSummaryLabel,
|
||||
resolvePayloadPublishedAt,
|
||||
scheduledStatusTooltip,
|
||||
selectPublishStatus,
|
||||
selectPublishTiming,
|
||||
settingsPublishDate,
|
||||
settingsPublishTime,
|
||||
togglePublishModalSection,
|
||||
unpublishModalKind,
|
||||
unpublishPersistedPublishedLabel
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
| composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 |
|
||||
| composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 |
|
||||
| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 |
|
||||
| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 |
|
||||
|
||||
## 공유 라이브러리(서버·클라이언트 공통)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- 거대 컴포넌트 책임 분리 진행.
|
||||
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
|
||||
- `components/admin/AdminPostForm.vue`: 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
||||
- `components/admin/AdminPostForm.vue`: 우측 설정 패널 책임을 분리.
|
||||
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.42
|
||||
|
||||
- 관리자 게시물 폼의 발행·예약·발행취소 상태 판별과 모달 상태를 `useAdminPostPublishState` composable로 분리.
|
||||
- 발행 시각 datetime-local 변환·예약 날짜 입력·발행취소 표시 문구 계산을 한 경로로 정리.
|
||||
- 게시물 저장 payload 생성은 폼에 유지하면서 발행 상태 관리 책임을 축소.
|
||||
|
||||
## v1.11.41
|
||||
|
||||
- 관리자 게시물 폼의 대표 이미지 선택·검색·페이지네이션·업로드 로직을 `useAdminFeaturedImagePicker` composable로 분리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.41",
|
||||
"version": "1.11.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.41",
|
||||
"version": "1.11.42",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.41",
|
||||
"version": "1.11.42",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user