From 46c9d8d9834bfcff0cb2b5477b93f8ea8485a3ee Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 24 Jul 2026 12:35:55 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EB=AC=BC=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EC=83=81=ED=83=9C=20=EB=A1=9C=EC=A7=81=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminPostForm.vue | 469 +++--------------------- composables/useAdminPostPublishState.js | 450 +++++++++++++++++++++++ docs/map.md | 1 + docs/todo.md | 2 +- docs/update.md | 6 + package-lock.json | 4 +- package.json | 2 +- 7 files changed, 505 insertions(+), 429 deletions(-) create mode 100644 composables/useAdminPostPublishState.js diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index 2d24150..856609b 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -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 diff --git a/composables/useAdminPostPublishState.js b/composables/useAdminPostPublishState.js new file mode 100644 index 0000000..6e6a122 --- /dev/null +++ b/composables/useAdminPostPublishState.js @@ -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} 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 + } +} diff --git a/docs/map.md b/docs/map.md index 357f912..6b7ace7 100644 --- a/docs/map.md +++ b/docs/map.md @@ -24,6 +24,7 @@ | composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 | | composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 | | composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 | +| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 | ## 공유 라이브러리(서버·클라이언트 공통) diff --git a/docs/todo.md b/docs/todo.md index bbb4a21..66efcf1 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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` 런타임 분기 축소 가능 여부 검토. diff --git a/docs/update.md b/docs/update.md index 3a02804..9c2a6c9 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.11.42 + +- 관리자 게시물 폼의 발행·예약·발행취소 상태 판별과 모달 상태를 `useAdminPostPublishState` composable로 분리. +- 발행 시각 datetime-local 변환·예약 날짜 입력·발행취소 표시 문구 계산을 한 경로로 정리. +- 게시물 저장 payload 생성은 폼에 유지하면서 발행 상태 관리 책임을 축소. + ## v1.11.41 - 관리자 게시물 폼의 대표 이미지 선택·검색·페이지네이션·업로드 로직을 `useAdminFeaturedImagePicker` composable로 분리. diff --git a/package-lock.json b/package-lock.json index 2c45155..0a026f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 370ba05..8cb6f51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.41", + "version": "1.11.42", "private": true, "type": "module", "imports": {