예약 발행 기능 추가
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
### 미디어 폴더 트리 관리 방식 결정
|
||||
|
||||
@@ -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 | 페이지 목록 |
|
||||
|
||||
@@ -289,6 +289,8 @@ components/content/
|
||||
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
|
||||
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
||||
- 글 저장/수정/삭제 진행과 성공/실패 상태는 화면 우측 상단 토스트로 표시한다.
|
||||
- 발행 상태에서 발행 시각을 미래로 지정하면 예약 발행으로 저장한다.
|
||||
- 예약 발행 글은 관리자 목록에서 예약 상태로 표시하되 공개 게시물 목록과 상세 API에는 발행 시각 이후부터 노출한다.
|
||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
|
||||
## 3차 관리자 개발
|
||||
|
||||
- [ ] 예약 발행
|
||||
- [ ] 초안 자동 저장
|
||||
- [ ] SEO 설정
|
||||
- [ ] OG 이미지 설정
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.28
|
||||
|
||||
- 관리자 글 작성/수정 폼에 발행 시각 입력 기능 추가.
|
||||
- 발행 상태에서 미래 발행 시각을 저장하면 예약 발행으로 처리하도록 추가.
|
||||
- 공개 게시물 목록과 상세 API가 미래 발행 글을 노출하지 않도록 수정.
|
||||
- 관리자 글 목록에서 발행/예약/초안/비공개 상태 표시를 구분하도록 수정.
|
||||
- 예약 발행 글은 공개 보기 버튼을 숨기도록 수정.
|
||||
- 패키지 버전을 0.0.28로 갱신.
|
||||
|
||||
## v0.0.27
|
||||
|
||||
- 미디어 폴더 테이블 추가.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.27",
|
||||
"version": "0.0.28",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(', ') || '-' }}
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user