예약 발행 기능 추가

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 autosaveStatus = ref('')
const isRestoringAutosave = ref(false) 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({ const form = reactive({
title: props.initialPost.title || '', title: props.initialPost.title || '',
slug: props.initialPost.slug || '', slug: props.initialPost.slug || '',
@@ -35,6 +75,7 @@ const form = reactive({
content: props.initialPost.content || '', content: props.initialPost.content || '',
featuredImage: props.initialPost.featuredImage || '', featuredImage: props.initialPost.featuredImage || '',
status: props.initialPost.status || 'draft', status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
tagsText: props.initialPost.tags?.join(', ') || '' tagsText: props.initialPost.tags?.join(', ') || ''
}) })
@@ -78,13 +119,23 @@ const parseTags = (value) => [...new Set(value
.map((tag) => toSlug(tag)) .map((tag) => toSlug(tag))
.filter(Boolean))] .filter(Boolean))]
/**
* 예약 발행 여부 확인
* @returns {boolean} 예약 발행 여부
*/
const isScheduledPost = () => {
const publishedAt = toIsoDateTime(form.publishedAt)
return form.status === 'published' && Boolean(publishedAt) && new Date(publishedAt) > new Date()
}
/** /**
* 게시물 입력값 생성 * 게시물 입력값 생성
* @returns {Object} 게시물 입력값 * @returns {Object} 게시물 입력값
*/ */
const createPostPayload = () => { const createPostPayload = () => {
const publishedAt = form.status === 'published' const publishedAt = form.status === 'published'
? props.initialPost.publishedAt || new Date().toISOString() ? toIsoDateTime(form.publishedAt) || props.initialPost.publishedAt || new Date().toISOString()
: null : null
return { return {
@@ -110,6 +161,7 @@ const createAutosavePayload = () => ({
content: form.content, content: form.content,
featuredImage: form.featuredImage, featuredImage: form.featuredImage,
status: form.status, status: form.status,
publishedAt: form.publishedAt,
tagsText: form.tagsText tagsText: form.tagsText
}) })
@@ -381,6 +433,20 @@ defineExpose({
</select> </select>
</label> </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"> <label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">슬러그</span> <span class="admin-post-form__label font-medium">슬러그</span>
<input <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 ## 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/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
@@ -57,7 +57,7 @@
|------|------| |------|------|
| pages/admin/index.vue | 대시보드 | | pages/admin/index.vue | 대시보드 |
| pages/admin/login.vue | 관리자 로그인 | | pages/admin/login.vue | 관리자 로그인 |
| pages/admin/posts/index.vue | 글 목록 | | pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시 |
| pages/admin/posts/new.vue | 글 작성, 저장 토스트 | | pages/admin/posts/new.vue | 글 작성, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, 저장/삭제 토스트 | | pages/admin/posts/[id].vue | 글 수정, 저장/삭제 토스트 |
| pages/admin/pages/index.vue | 페이지 목록 | | pages/admin/pages/index.vue | 페이지 목록 |

View File

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

View File

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

View File

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

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "0.0.27", "version": "0.0.28",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sori.studio", "name": "sori.studio",
"version": "0.0.27", "version": "0.0.28",
"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": "0.0.27", "version": "0.0.28",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "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 - 토스트 타입 * @param {'success'|'error'|'info'} type - 토스트 타입
@@ -127,7 +135,7 @@ onBeforeUnmount(() => {
</div> </div>
<div class="admin-post-edit__actions flex gap-2"> <div class="admin-post-edit__actions flex gap-2">
<NuxtLink <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" class="admin-post-edit__view rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
:to="`/post/${post.slug}`" :to="`/post/${post.slug}`"
target="_blank" target="_blank"

View File

@@ -28,6 +28,35 @@ const formatDate = (value) => {
return `${year}.${month}.${day}` 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 - 삭제할 게시물 * @param {Object} post - 삭제할 게시물
@@ -96,7 +125,20 @@ const deletePost = async (post) => {
</p> </p>
</td> </td>
<td class="admin-posts__cell px-4 py-4"> <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>
<td class="admin-posts__cell px-4 py-4"> <td class="admin-posts__cell px-4 py-4">
{{ post.tags.join(', ') || '-' }} {{ 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 post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE posts.status = 'published' WHERE posts.status = 'published'
AND (
posts.published_at IS NULL
OR posts.published_at <= now()
)
GROUP BY posts.id GROUP BY posts.id
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC 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 LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE posts.slug = ${slug} WHERE posts.slug = ${slug}
AND posts.status = 'published' AND posts.status = 'published'
AND (
posts.published_at IS NULL
OR posts.published_at <= now()
)
GROUP BY posts.id GROUP BY posts.id
LIMIT 1 LIMIT 1
` `