예약 발행 기능 추가

This commit is contained in:
2026-05-03 09:58:27 +09:00
parent db87542096
commit 60f9fd52f0
11 changed files with 151 additions and 9 deletions

View File

@@ -28,6 +28,46 @@ const autosaveNotice = ref(null)
const autosaveStatus = ref('')
const isRestoringAutosave = ref(false)
/**
* 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()
}
const form = reactive({
title: props.initialPost.title || '',
slug: props.initialPost.slug || '',
@@ -35,6 +75,7 @@ const form = reactive({
content: props.initialPost.content || '',
featuredImage: props.initialPost.featuredImage || '',
status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
tagsText: props.initialPost.tags?.join(', ') || ''
})
@@ -78,13 +119,23 @@ const parseTags = (value) => [...new Set(value
.map((tag) => toSlug(tag))
.filter(Boolean))]
/**
* 예약 발행 여부 확인
* @returns {boolean} 예약 발행 여부
*/
const isScheduledPost = () => {
const publishedAt = toIsoDateTime(form.publishedAt)
return form.status === 'published' && Boolean(publishedAt) && new Date(publishedAt) > new Date()
}
/**
* 게시물 입력값 생성
* @returns {Object} 게시물 입력값
*/
const createPostPayload = () => {
const publishedAt = form.status === 'published'
? props.initialPost.publishedAt || new Date().toISOString()
? toIsoDateTime(form.publishedAt) || props.initialPost.publishedAt || new Date().toISOString()
: null
return {
@@ -110,6 +161,7 @@ const createAutosavePayload = () => ({
content: form.content,
featuredImage: form.featuredImage,
status: form.status,
publishedAt: form.publishedAt,
tagsText: form.tagsText
})
@@ -381,6 +433,20 @@ defineExpose({
</select>
</label>
<label v-if="form.status === 'published'" class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">
발행 시각
</span>
<input
v-model="form.publishedAt"
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
type="datetime-local"
>
<span class="admin-post-form__hint text-xs text-muted">
{{ isScheduledPost() ? '미래 시각이면 예약 발행으로 저장됩니다.' : '비워두면 저장 시점으로 발행됩니다.' }}
</span>
</label>
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">슬러그</span>
<input

View File

@@ -1,5 +1,13 @@
# 의사결정 이력
## 2026-05-03 v0.0.28
### 예약 발행 저장 방식 결정
예약 발행은 별도 `scheduled` 상태를 추가하지 않고 기존 `published` 상태와 미래 `published_at` 값을 조합해 처리한다. 현재 데이터베이스의 게시물 상태 제약은 `published`, `draft`, `private`만 허용하고 있으므로 상태값을 늘리기보다 공개 API의 조회 조건으로 발행 시각을 확인하는 편이 변경 범위가 작다.
관리자 목록에서는 미래 발행 시각을 가진 `published` 게시물을 예약 상태로 표시한다. 공개 목록과 상세 API는 `published_at`이 비어 있거나 현재 시각 이전인 발행 글만 노출한다.
## 2026-05-02 v0.0.27
### 미디어 폴더 트리 관리 방식 결정

View File

@@ -26,7 +26,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
@@ -57,7 +57,7 @@
|------|------|
| pages/admin/index.vue | 대시보드 |
| pages/admin/login.vue | 관리자 로그인 |
| pages/admin/posts/index.vue | 글 목록 |
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시 |
| pages/admin/posts/new.vue | 글 작성, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, 저장/삭제 토스트 |
| pages/admin/pages/index.vue | 페이지 목록 |

View File

@@ -289,6 +289,8 @@ components/content/
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
- 글 저장/수정/삭제 진행과 성공/실패 상태는 화면 우측 상단 토스트로 표시한다.
- 발행 상태에서 발행 시각을 미래로 지정하면 예약 발행으로 저장한다.
- 예약 발행 글은 관리자 목록에서 예약 상태로 표시하되 공개 게시물 목록과 상세 API에는 발행 시각 이후부터 노출한다.
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.

View File

@@ -21,7 +21,6 @@
## 3차 관리자 개발
- [ ] 예약 발행
- [ ] 초안 자동 저장
- [ ] SEO 설정
- [ ] OG 이미지 설정

View File

@@ -1,5 +1,14 @@
# 업데이트 이력
## v0.0.28
- 관리자 글 작성/수정 폼에 발행 시각 입력 기능 추가.
- 발행 상태에서 미래 발행 시각을 저장하면 예약 발행으로 처리하도록 추가.
- 공개 게시물 목록과 상세 API가 미래 발행 글을 노출하지 않도록 수정.
- 관리자 글 목록에서 발행/예약/초안/비공개 상태 표시를 구분하도록 수정.
- 예약 발행 글은 공개 보기 버튼을 숨기도록 수정.
- 패키지 버전을 0.0.28로 갱신.
## v0.0.27
- 미디어 폴더 테이블 추가.

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.27",
"version": "0.0.28",
"private": true,
"type": "module",
"scripts": {

View File

@@ -21,6 +21,14 @@ if (!post.value) {
})
}
/**
* 공개 화면에서 접근 가능한 게시물 여부 확인
* @param {Object} value - 게시물
* @returns {boolean} 공개 접근 가능 여부
*/
const isPublicPost = (value) => value?.status === 'published'
&& (!value.publishedAt || new Date(value.publishedAt) <= new Date())
/**
* 저장 상태 토스트 표시
* @param {'success'|'error'|'info'} type - 토스트 타입
@@ -127,7 +135,7 @@ onBeforeUnmount(() => {
</div>
<div class="admin-post-edit__actions flex gap-2">
<NuxtLink
v-if="post.status === 'published'"
v-if="isPublicPost(post)"
class="admin-post-edit__view rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
:to="`/post/${post.slug}`"
target="_blank"

View File

@@ -28,6 +28,35 @@ const formatDate = (value) => {
return `${year}.${month}.${day}`
}
/**
* 게시물 공개 여부 확인
* @param {Object} post - 게시물
* @returns {boolean} 공개 여부
*/
const isPublicPost = (post) => post.status === 'published'
&& (!post.publishedAt || new Date(post.publishedAt) <= new Date())
/**
* 게시물 상태 표시 문자열 생성
* @param {Object} post - 게시물
* @returns {string} 상태 표시 문자열
*/
const getPostStatusLabel = (post) => {
if (post.status === 'published' && !isPublicPost(post)) {
return '예약'
}
if (post.status === 'published') {
return '발행'
}
if (post.status === 'private') {
return '비공개'
}
return '초안'
}
/**
* 게시물 삭제
* @param {Object} post - 삭제할 게시물
@@ -96,7 +125,20 @@ const deletePost = async (post) => {
</p>
</td>
<td class="admin-posts__cell px-4 py-4">
{{ post.status }}
<span
class="admin-posts__status rounded px-2 py-1 text-xs font-semibold"
:class="{
'bg-green-50 text-green-700': getPostStatusLabel(post) === '발행',
'bg-blue-50 text-blue-700': getPostStatusLabel(post) === '예약',
'bg-[#f5f5f2] text-muted': getPostStatusLabel(post) === '초안',
'bg-red-50 text-red-700': getPostStatusLabel(post) === '비공개'
}"
>
{{ getPostStatusLabel(post) }}
</span>
<p v-if="post.publishedAt" class="admin-posts__published-at mt-1 text-xs text-muted">
{{ formatDate(post.publishedAt) }}
</p>
</td>
<td class="admin-posts__cell px-4 py-4">
{{ post.tags.join(', ') || '-' }}

View File

@@ -158,6 +158,10 @@ export const listPosts = async () => {
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE posts.status = 'published'
AND (
posts.published_at IS NULL
OR posts.published_at <= now()
)
GROUP BY posts.id
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
`
@@ -343,6 +347,10 @@ export const getPostBySlug = async (slug) => {
LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE posts.slug = ${slug}
AND posts.status = 'published'
AND (
posts.published_at IS NULL
OR posts.published_at <= now()
)
GROUP BY posts.id
LIMIT 1
`