게시물 발행 상태 로직 분리

This commit is contained in:
2026-07-24 12:35:55 +09:00
parent 8bb6784c66
commit 46c9d8d983
7 changed files with 505 additions and 429 deletions

View File

@@ -4,6 +4,7 @@ import {
toAdminPostFormTitle, toAdminPostFormTitle,
toAdminPostStoredTitle toAdminPostStoredTitle
} from '../../lib/admin-post-title.js' } from '../../lib/admin-post-title.js'
import { toAdminPostDateTimeLocalValue } from '../../composables/useAdminPostPublishState.js'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js' import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { DEFAULT_POST_TAG_LIMIT } from '../../lib/post-tag-limit.js' import { DEFAULT_POST_TAG_LIMIT } from '../../lib/post-tag-limit.js'
@@ -64,12 +65,6 @@ const isTitleInputComposing = ref(false)
const savedPostSnapshot = ref('') const savedPostSnapshot = ref('')
const failedDraftAutosaveSnapshot = ref('') const failedDraftAutosaveSnapshot = ref('')
const slugFieldError = 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', { const { data: adminTags } = useFetch('/admin/api/tags', {
default: () => [] default: () => []
@@ -81,72 +76,6 @@ const { data: siteSettings } = useFetch('/admin/api/settings', {
/** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */ /** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */
const READING_CHARS_PER_MINUTE = 600 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({ const form = reactive({
title: toAdminPostFormTitle(props.initialPost.title), title: toAdminPostFormTitle(props.initialPost.title),
slug: props.initialPost.slug || '', slug: props.initialPost.slug || '',
@@ -157,10 +86,49 @@ const form = reactive({
isFeatured: Boolean(props.initialPost.isFeatured), isFeatured: Boolean(props.initialPost.isFeatured),
noindex: props.initialPost.noindex === true, noindex: props.initialPost.noindex === true,
status: props.initialPost.status || 'draft', status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt), publishedAt: toAdminPostDateTimeLocalValue(props.initialPost.publishedAt),
tagsText: props.initialPost.tags?.join(', ') || '' 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 - 숫자 값 * @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 autosaveKey = computed(() => `${autosaveStoragePrefix}:${props.initialPost.id || 'new'}`)
const savedPostSlug = computed(() => toSlug(props.initialPost?.slug || '')) const savedPostSlug = computed(() => toSlug(props.initialPost?.slug || ''))
@@ -460,112 +388,6 @@ const {
form 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] - 신규 발행 시각 허용 여부 * @param {{ allowNewPublishTimestamp?: boolean }} [options] - 신규 발행 시각 허용 여부
@@ -673,26 +495,6 @@ const headerDraftStatusText = computed(() => {
return props.initialPost?.id ? 'Draft - Saved' : 'Draft' 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} * @returns {void}
@@ -910,22 +712,6 @@ const requestToolbarUpdate = () => {
emit('submit', createPostPayload()) emit('submit', createPostPayload())
} }
/**
* 발행 취소·예약 취소 확인 화면을 연다.
* @returns {void}
*/
const openUnpublishModal = () => {
isUnpublishModalOpen.value = true
}
/**
* 발행 취소·예약 취소 확인 화면을 닫는다.
* @returns {void}
*/
const closeUnpublishModal = () => {
isUnpublishModalOpen.value = false
}
/** /**
* 발행 취소 또는 예약 취소(초안으로 되돌림)를 확정한다. * 발행 취소 또는 예약 취소(초안으로 되돌림)를 확정한다.
* @returns {void} * @returns {void}
@@ -960,62 +746,12 @@ const toggleSettingsPanel = () => {
isSettingsOpen.value = !isSettingsOpen.value 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 = () => { const openPublishModal = () => {
if (hasBlockingSlugError.value) { if (hasBlockingSlugError.value) {
return return
} }
syncPublishModalStateFromForm() openPublishModalState()
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())
} }
/** /**
@@ -1030,104 +766,6 @@ const submitFromPublishModal = () => {
submitPost() 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] - 실제 서버에 저장된 입력값 * @param {Object|null} [savedPayload] - 실제 서버에 저장된 입력값
@@ -1138,10 +776,7 @@ const markSaved = (savedPayload = null) => {
failedDraftAutosaveSnapshot.value = '' failedDraftAutosaveSnapshot.value = ''
slugFieldError.value = '' slugFieldError.value = ''
const publishedAt = resolvePayloadPublishedAt() const publishedAt = resolvePayloadPublishedAt()
markPublishStateSaved(publishedAt)
if (publishedAt) {
persistedPublishedAtIso.value = publishedAt
}
} }
/** /**
@@ -1190,22 +825,6 @@ const takePendingAutosavePayload = () => {
return createPostPayload() 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, () => { watch(serializedPostFingerprint, () => {
if (!import.meta.client) { if (!import.meta.client) {
return return

View 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
}
}

View File

@@ -24,6 +24,7 @@
| composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 | | composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 |
| composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 | | composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 |
| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 | | composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 |
| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 |
## 공유 라이브러리(서버·클라이언트 공통) ## 공유 라이브러리(서버·클라이언트 공통)

View File

@@ -5,7 +5,7 @@
- 거대 컴포넌트 책임 분리 진행. - 거대 컴포넌트 책임 분리 진행.
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리. - `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리. - `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
- `components/admin/AdminPostForm.vue`: 발행/초안 저장 상태, 우측 설정 패널 책임을 분리. - `components/admin/AdminPostForm.vue`: 우측 설정 패널 책임을 분리.
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리. - `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
- 미디어 카탈로그 전환 코드 수명 정리. - 미디어 카탈로그 전환 코드 수명 정리.
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토. - 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력 # 업데이트 이력
## v1.11.42
- 관리자 게시물 폼의 발행·예약·발행취소 상태 판별과 모달 상태를 `useAdminPostPublishState` composable로 분리.
- 발행 시각 datetime-local 변환·예약 날짜 입력·발행취소 표시 문구 계산을 한 경로로 정리.
- 게시물 저장 payload 생성은 폼에 유지하면서 발행 상태 관리 책임을 축소.
## v1.11.41 ## v1.11.41
- 관리자 게시물 폼의 대표 이미지 선택·검색·페이지네이션·업로드 로직을 `useAdminFeaturedImagePicker` composable로 분리. - 관리자 게시물 폼의 대표 이미지 선택·검색·페이지네이션·업로드 로직을 `useAdminFeaturedImagePicker` composable로 분리.

4
package-lock.json generated
View File

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

View File

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