운영 시작 버전 v1.0.0 정리

This commit is contained in:
2026-05-14 10:49:25 +09:00
parent 069d1bfbd4
commit 3b331b8fe6
18 changed files with 1679 additions and 94 deletions

View File

@@ -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">&lt;</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>