운영 시작 버전 v1.0.0 정리
This commit is contained in:
@@ -51,6 +51,11 @@ const isTitleInputComposing = ref(false)
|
||||
const activeMediaPickerTab = ref('upload')
|
||||
const selectedMediaPickerUrl = ref('')
|
||||
const savedPostSnapshot = ref('')
|
||||
const isPublishModalOpen = ref(false)
|
||||
const publishStatus = ref('draft')
|
||||
const publishTiming = ref('now')
|
||||
const scheduledPublishAt = ref('')
|
||||
const publishModalExpandedSection = ref(null)
|
||||
|
||||
/**
|
||||
* ISO 날짜를 datetime-local 입력값으로 변환
|
||||
@@ -236,6 +241,49 @@ const editorStatusLabel = computed(() => {
|
||||
return '초안'
|
||||
})
|
||||
|
||||
/**
|
||||
* 발행 모달에 표시할 게시 상태 요약 문구
|
||||
* @returns {string} 요약 문구
|
||||
*/
|
||||
const publishStatusSummaryLabel = computed(() => {
|
||||
if (publishStatus.value === 'published') {
|
||||
return '발행'
|
||||
}
|
||||
|
||||
if (publishStatus.value === 'private') {
|
||||
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)
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물 입력값 생성
|
||||
* @returns {Object} 게시물 입력값
|
||||
@@ -580,6 +628,7 @@ const focusContentEditor = (event) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitPost = () => {
|
||||
isPublishModalOpen.value = false
|
||||
emit('submit', createPostPayload())
|
||||
}
|
||||
|
||||
@@ -607,6 +656,100 @@ const toggleSettingsPanel = () => {
|
||||
isSettingsOpen.value = !isSettingsOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달을 현재 폼 상태로 초기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncPublishModalStateFromForm = () => {
|
||||
publishStatus.value = form.status || 'draft'
|
||||
scheduledPublishAt.value = form.publishedAt || toDateTimeLocalValue(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 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())
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 최종 저장/발행 확정
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitFromPublishModal = () => {
|
||||
applyPublishSelectionToForm()
|
||||
submitPost()
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 설정 행 펼침을 토글한다.
|
||||
* @param {'status' | 'timing'} section - 펼칠 행
|
||||
* @returns {void}
|
||||
*/
|
||||
const togglePublishModalSection = (section) => {
|
||||
publishModalExpandedSection.value =
|
||||
publishModalExpandedSection.value === section ? null : section
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 게시 상태를 선택한다.
|
||||
* @param {'published' | 'draft' | 'private'} 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 입력값을 저장 완료 기준점으로 표시한다.
|
||||
* @returns {void}
|
||||
@@ -615,6 +758,16 @@ const markSaved = () => {
|
||||
savedPostSnapshot.value = serializePostPayload()
|
||||
}
|
||||
|
||||
watch(publishStatus, (next) => {
|
||||
if (next !== 'published') {
|
||||
publishTiming.value = 'now'
|
||||
|
||||
if (publishModalExpandedSection.value === 'timing') {
|
||||
publishModalExpandedSection.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(form, scheduleAutosave, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
@@ -652,18 +805,40 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="admin-post-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="submitPost">
|
||||
<form class="admin-post-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="openPublishModal">
|
||||
<div class="admin-post-form__workspace flex min-w-0 flex-1 flex-col bg-white">
|
||||
<header class="admin-post-form__toolbar flex h-[56px] shrink-0 items-center bg-white px-8">
|
||||
<div class="admin-post-form__toolbar-inner flex h-[34px] min-w-0 flex-1 items-center justify-between">
|
||||
<div class="admin-post-form__toolbar-left flex h-full min-w-0 items-center gap-3">
|
||||
<NuxtLink class="admin-post-form__toolbar-link inline-flex items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black" to="/admin/posts">
|
||||
<div class="admin-post-form__toolbar-left flex h-full min-w-0 flex-1 items-center gap-3">
|
||||
<NuxtLink class="admin-post-form__toolbar-link inline-flex shrink-0 items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black" to="/admin/posts">
|
||||
<span class="admin-post-form__toolbar-back text-lg leading-none" aria-hidden="true"><</span>
|
||||
<span>Posts</span>
|
||||
</NuxtLink>
|
||||
<span class="admin-post-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8e9cac]">
|
||||
{{ editorStatusLabel }}
|
||||
</span>
|
||||
<div class="admin-post-form__toolbar-status-row flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="admin-post-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8e9cac]">
|
||||
{{ editorStatusLabel }}
|
||||
</span>
|
||||
<div
|
||||
v-if="autosaveNotice"
|
||||
class="admin-post-form__toolbar-autosave-actions flex shrink-0 items-center gap-1.5"
|
||||
>
|
||||
<button
|
||||
class="admin-post-form__toolbar-autosave-restore rounded px-2 py-1 text-xs font-semibold text-[#15171a] ring-1 ring-inset ring-[#d7dde2] transition-colors hover:bg-[#eff1f2]"
|
||||
type="button"
|
||||
@click="restoreAutosave"
|
||||
>
|
||||
복원
|
||||
</button>
|
||||
<button
|
||||
class="admin-post-form__toolbar-autosave-discard rounded px-2 py-1 text-xs font-semibold text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
|
||||
type="button"
|
||||
title="이 기기에만 있는 자동 저장 초안을 삭제합니다"
|
||||
@click="discardAutosave"
|
||||
>
|
||||
무시
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-post-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
|
||||
<button
|
||||
@@ -675,8 +850,9 @@ defineExpose({
|
||||
</button>
|
||||
<button
|
||||
class="admin-post-form__toolbar-submit rounded px-3 py-1.5 text-sm font-bold text-[#2bba3c] transition-colors hover:bg-[#eaf8ec] hover:text-[#159624] disabled:pointer-events-none disabled:text-[#8e9cac] disabled:opacity-60"
|
||||
type="submit"
|
||||
type="button"
|
||||
:disabled="saving || !hasUnsavedPostChanges"
|
||||
@click="openPublishModal"
|
||||
>
|
||||
{{ saving ? '저장 중' : submitLabel }}
|
||||
</button>
|
||||
@@ -728,23 +904,6 @@ defineExpose({
|
||||
@compositionend="isTitleInputComposing = false"
|
||||
>
|
||||
|
||||
<div
|
||||
v-if="autosaveNotice"
|
||||
class="admin-post-form__autosave-notice flex flex-wrap items-center justify-between gap-3 rounded border border-line bg-surface px-4 py-3 text-sm"
|
||||
>
|
||||
<p class="admin-post-form__autosave-message text-muted">
|
||||
{{ formatAutosaveTime(autosaveNotice.savedAt) }}에 저장된 작성 중 내용이 있습니다.
|
||||
</p>
|
||||
<div class="admin-post-form__autosave-actions flex gap-2">
|
||||
<button class="admin-post-form__autosave-restore rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-black" type="button" @click="restoreAutosave">
|
||||
복원
|
||||
</button>
|
||||
<button class="admin-post-form__autosave-discard rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-[#eff1f2]" type="button" @click="discardAutosave">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__field admin-post-form__content-editor text-sm">
|
||||
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
|
||||
</div>
|
||||
@@ -998,5 +1157,177 @@ defineExpose({
|
||||
@stay="stayOnUnsavedPage"
|
||||
@leave="leaveUnsavedPage"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isPublishModalOpen"
|
||||
class="admin-post-form__publish-modal fixed inset-0 z-[70] flex flex-col bg-white"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-post-form-publish-modal-title"
|
||||
>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<header class="admin-post-form__publish-modal-header flex h-14 shrink-0 items-center justify-between px-6">
|
||||
<h2 id="admin-post-form-publish-modal-title" class="text-[15px] font-semibold text-[#15171a]">
|
||||
발행
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="rounded px-3 py-1.5 text-[13px] font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closePublishModal">
|
||||
닫기
|
||||
</button>
|
||||
<button class="rounded border border-[#d7dde2] px-3 py-1.5 text-[13px] font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="previewPost">
|
||||
미리보기
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-post-form__publish-modal-body flex flex-1 flex-col items-center px-6 pb-16 pt-10 sm:pt-14">
|
||||
<div class="w-full max-w-[640px]">
|
||||
<div class="admin-post-form__publish-modal-hero mb-10 sm:mb-12">
|
||||
<p class="text-[clamp(28px,7vw,46px)] font-black text-[#2bba3c]">
|
||||
준비됐어요, 발행하세요.
|
||||
</p>
|
||||
<p class="text-[clamp(28px,7vw,46px)] font-black leading-[0.95] text-[#15171a]">
|
||||
세상과 공유해 보세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__publish-settings w-full">
|
||||
<div class="admin-post-form__publish-setting">
|
||||
<button
|
||||
class="admin-post-form__publish-setting-title flex w-full items-center gap-3 py-4 text-left text-[15px] font-semibold text-[#15171a] transition-colors"
|
||||
type="button"
|
||||
:aria-expanded="publishModalExpandedSection === 'status'"
|
||||
aria-controls="admin-post-form-publish-status-panel"
|
||||
data-test-setting="publish-type"
|
||||
@click="togglePublishModalSection('status')"
|
||||
>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
|
||||
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23 1L6.21 13.013v9.408L12 17.355" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="M1 9.105L23 1l-3.474 22L1 9.105z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">{{ publishStatusSummaryLabel }}</span>
|
||||
<span class="flex size-2.5 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
|
||||
<svg
|
||||
class="size-[22px] transition-transform duration-200 ease-out"
|
||||
:class="{ 'rotate-180': publishModalExpandedSection === 'status' }"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 26 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-show="publishModalExpandedSection === 'status'"
|
||||
id="admin-post-form-publish-status-panel"
|
||||
class="admin-post-form__publish-setting-panel px-4 pb-4"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
|
||||
:class="publishStatus === 'published' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
|
||||
type="button"
|
||||
@click="selectPublishStatus('published')"
|
||||
>
|
||||
발행
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
|
||||
:class="publishStatus === 'draft' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
|
||||
type="button"
|
||||
@click="selectPublishStatus('draft')"
|
||||
>
|
||||
초안
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
|
||||
:class="publishStatus === 'private' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
|
||||
type="button"
|
||||
@click="selectPublishStatus('private')"
|
||||
>
|
||||
비공개
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="publishStatus === 'published'" class="admin-post-form__publish-setting admin-post-form__publish-setting--timing border-t border-[#e3e6e8]">
|
||||
<button
|
||||
class="admin-post-form__publish-setting-title flex w-full items-center gap-3 py-4 text-left text-[15px] font-semibold text-[#15171a] transition-colors"
|
||||
type="button"
|
||||
:aria-expanded="publishModalExpandedSection === 'timing'"
|
||||
aria-controls="admin-post-form-publish-timing-panel"
|
||||
data-test-setting="publish-at"
|
||||
@click="togglePublishModalSection('timing')"
|
||||
>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
|
||||
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 23c6.075 0 11-4.925 11-11S18.075 1 12 1 1 5.925 1 12s4.925 11 11 11z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="M12 6v6h6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">{{ publishTimingSummaryLabel }}</span>
|
||||
<span class="flex size-2.5 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
|
||||
<svg
|
||||
class="size-[22px] transition-transform duration-200 ease-out"
|
||||
:class="{ 'rotate-180': publishModalExpandedSection === 'timing' }"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 26 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-show="publishModalExpandedSection === 'timing'"
|
||||
id="admin-post-form-publish-timing-panel"
|
||||
class="admin-post-form__publish-setting-panel px-4 pb-4"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
|
||||
:class="publishTiming === 'now' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
|
||||
type="button"
|
||||
@click="selectPublishTiming('now')"
|
||||
>
|
||||
지금 바로
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
|
||||
:class="publishTiming === 'schedule' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
|
||||
type="button"
|
||||
@click="selectPublishTiming('schedule')"
|
||||
>
|
||||
예약
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-if="publishTiming === 'schedule'"
|
||||
v-model="scheduledPublishAt"
|
||||
class="admin-post-form__publish-schedule-input mt-3 h-[38px] w-full max-w-[320px] rounded border border-[#e3e6e8] bg-white px-3 py-2 text-[13px] text-[#15171a] outline-none focus:border-[#8e9cac]"
|
||||
type="datetime-local"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__publish-modal-actions mt-10">
|
||||
<button
|
||||
class="rounded bg-[#15171a] px-5 py-2.5 text-[14px] font-semibold text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:bg-[#d7dce0] disabled:text-[#8e9cac]"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="submitFromPublishModal"
|
||||
>
|
||||
{{ saving ? '저장 중…' : '최종 확인하고 저장 →' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user