게시물과 페이지 공개 상태 확장 v1.5.4

This commit is contained in:
2026-05-26 16:07:10 +09:00
parent b989193dab
commit 6333c4254f
20 changed files with 252 additions and 38 deletions

View File

@@ -43,6 +43,7 @@ const htmlCursorRange = reactive({
const form = reactive({
title: props.initialPage.title || '',
slug: props.initialPage.slug || '',
status: props.initialPage.status || 'published',
renderMode: props.initialPage.renderMode || 'html_document',
content: props.initialPage.content || ''
})
@@ -101,6 +102,7 @@ const pageUrlHint = computed(() => `/pages/${pageSlug.value || 'page-slug'}/`)
const serializePageForm = () => JSON.stringify({
title: form.title.trim(),
slug: pageSlug.value,
status: form.status,
renderMode: form.renderMode,
content: form.content
})
@@ -242,6 +244,7 @@ const uploadPageAsset = async (event) => {
const createPayload = () => ({
title: form.title.trim(),
slug: pageSlug.value,
status: form.status,
renderMode: form.renderMode,
content: form.content,
featuredImage: null
@@ -419,6 +422,27 @@ defineExpose({
</p>
</div>
<label class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">상태</span>
<span class="admin-page-form__select-wrap relative block">
<select v-model="form.status" class="admin-page-form__select h-[38px] w-full appearance-none rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-10 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none">
<option value="draft">초안</option>
<option value="published">공개</option>
<option value="private">비공개</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<span v-if="form.status === 'draft'" class="admin-page-form__hint text-xs text-muted">
초안 페이지는 공개 URL에서 보이지 않습니다.
</span>
<span v-else-if="form.status === 'private'" class="admin-page-form__hint text-xs text-muted">
비공개 페이지는 공개 URL에서 보이지 않습니다.
</span>
</label>
<div v-if="form.renderMode === 'html_document'" class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">페이지 형식</span>
<div class="admin-page-form__mode-control grid grid-cols-2 rounded border border-[#e3e6e8] bg-[#eff1f2] p-1">

View File

@@ -47,8 +47,8 @@ const slugTouched = ref((() => {
if (!post?.id) {
return false
}
const status = post.status === 'private' ? 'draft' : post.status
if (status === 'published') {
const status = post.status || 'draft'
if (status !== 'draft') {
return true
}
return Boolean(post.slug) && !isAdminPostDraftPlaceholderSlug(post.slug)
@@ -160,7 +160,7 @@ const form = reactive({
featuredImage: props.initialPost.featuredImage || '',
isFeatured: Boolean(props.initialPost.isFeatured),
noindex: props.initialPost.noindex === true,
status: props.initialPost.status === 'private' ? 'draft' : (props.initialPost.status || 'draft'),
status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
tagsText: props.initialPost.tags?.join(', ') || ''
})
@@ -168,13 +168,16 @@ const form = reactive({
/**
* 서버에 반영된 게시 형태(툴바·자동 저장·슬러그 자동 연동 분기)
* @param {Object} post - 게시물
* @returns {'draft' | 'publishedLive' | 'scheduled'}
* @returns {'draft' | 'publishedLive' | 'scheduled' | 'members' | 'private'}
*/
const getPersistedPublishKind = (post) => {
if (!post?.id) {
return 'draft'
}
const st = post.status === 'private' ? 'draft' : post.status
const st = post.status || 'draft'
if (st === 'members' || st === 'private') {
return st
}
if (st !== 'published') {
return 'draft'
}
@@ -425,6 +428,16 @@ const displayScheduled = computed(() =>
persistedPublishKind.value === 'scheduled'
|| (persistedPublishKind.value === 'draft' && isScheduledPost()))
/** 툴바·상태줄에 멤버십 글로 표시할지 */
const displayMembersOnly = computed(() =>
persistedPublishKind.value === 'members'
|| (persistedPublishKind.value === 'draft' && form.status === 'members'))
/** 툴바·상태줄에 비공개 글로 표시할지 */
const displayPrivatePost = computed(() =>
persistedPublishKind.value === 'private'
|| (persistedPublishKind.value === 'draft' && form.status === 'private'))
/**
* 발행 모달에 표시할 게시 상태 요약 문구
* @returns {string} 요약 문구
@@ -556,7 +569,7 @@ const isRouteLeaveDirty = computed(() => {
if (!hasUnsavedPostChanges.value) {
return false
}
if (persistedPublishKind.value === 'publishedLive' || persistedPublishKind.value === 'scheduled') {
if (['publishedLive', 'scheduled', 'members', 'private'].includes(persistedPublishKind.value)) {
return true
}
return false
@@ -570,7 +583,7 @@ const headerDraftStatusText = computed(() => {
if (persistedPublishKind.value !== 'draft') {
return null
}
if (displayPublishedLive.value || displayScheduled.value) {
if (displayPublishedLive.value || displayScheduled.value || displayMembersOnly.value || displayPrivatePost.value) {
return null
}
const persisting = props.saving || props.autoSaving
@@ -1416,6 +1429,18 @@ defineExpose({
>
Scheduled
</span>
<span
v-else-if="displayMembersOnly"
class="admin-post-form__toolbar-status-members truncate rounded px-2 py-1.5 text-sm font-medium text-[#5a63d8]"
>
Members
</span>
<span
v-else-if="displayPrivatePost"
class="admin-post-form__toolbar-status-private truncate rounded px-2 py-1.5 text-sm font-medium text-[#fb2d8d]"
>
Private
</span>
<span
v-else-if="headerDraftStatusText"
class="admin-post-form__toolbar-status-draft truncate rounded px-2 py-1.5 text-sm text-[#8E9CAC]"
@@ -1488,6 +1513,17 @@ defineExpose({
</button>
</template>
<template v-else-if="persistedPublishKind === 'members' || persistedPublishKind === 'private'">
<button
class="admin-post-form__toolbar-update rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
type="button"
:disabled="!hasUnsavedPostChanges || saving || autoSaving"
@click="requestToolbarUpdate"
>
Update
</button>
</template>
<button
class="admin-post-form__settings-toggle grid size-[34px] place-items-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] hover:text-black"
type="button"
@@ -1614,12 +1650,20 @@ defineExpose({
<select v-model="form.status" class="admin-post-form__select h-[38px] w-full appearance-none rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-10 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none">
<option value="draft">초안</option>
<option value="published">발행</option>
<option value="members">멤버십</option>
<option value="private">비공개</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<span v-if="form.status === 'members'" class="admin-post-form__hint text-xs text-muted">
로그인한 회원에게만 공개됩니다. 등급별 제한은 이후 멤버십 등급 기능에서 확장합니다.
</span>
<span v-else-if="form.status === 'private'" class="admin-post-form__hint text-xs text-muted">
공개 사용자 화면에서는 보이지 않습니다.
</span>
</label>
<div v-if="form.status === 'published'" class="admin-post-form__field grid gap-2 text-sm">

View File

@@ -0,0 +1,15 @@
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_status_check;
ALTER TABLE posts
ADD CONSTRAINT posts_status_check CHECK (status IN ('published', 'draft', 'members', 'private'));
ALTER TABLE pages
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'published';
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_status_check;
ALTER TABLE pages
ADD CONSTRAINT pages_status_check CHECK (status IN ('published', 'draft', 'private'));
CREATE INDEX IF NOT EXISTS pages_status_updated_at_idx
ON pages (status, updated_at DESC);

View File

@@ -1,5 +1,10 @@
# 업데이트 요약
## v1.5.4
- 게시물에 멤버십·비공개 상태를 추가하고, 공개 화면에서는 상태에 맞는 글만 보이도록 정리했다.
- 페이지에도 초안·공개·비공개 상태를 추가하고, 공개 상태 페이지만 `/pages/:slug`에서 응답하도록 바꿨다.
## v1.5.3
- 페이지 작성 기본값을 HTML 문서 모드로 바꾸고, 페이지 슬러그도 한글 제목에서 영문으로 자동 생성되도록 개선했다.

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-26 v1.5.4 — 게시물·페이지 공개 상태를 다시 확장
게시물은 초안·발행·예약만으로는 운영자가 숨겨야 하는 글과 회원에게만 보여야 하는 글을 구분하기 어렵다. 따라서 `private`는 공개 화면에서 완전히 숨기는 상태로 되살리고, `members`는 현재 구현 가능한 회원 세션 기준 공개 상태로 추가한다. 아직 등급 시스템이 없으므로 등급별 제한은 후속 멤버십 권한 모델에서 확장한다.
고정 페이지도 운영 전이지만 HTML 랜딩 페이지를 붙여넣어 쓰는 구조라 공개 전 확인이나 비공개 보관 상태가 필요하다. 페이지는 게시물과 달리 멤버십 공개 요구가 아직 없으므로 `draft`, `published`, `private`만 두고, 공개 API와 HTML 문서 미들웨어는 `published`만 응답하게 정리한다.
## 2026-05-26 v1.5.3 — 고정 페이지 기본값을 HTML 랜딩 페이지 작성에 맞춤
고정 페이지는 운영에서 아직 본격 사용 전이고, 앞으로는 단일 랜딩 페이지 HTML을 붙여넣어 공개 URL에서 원문 HTML로 응답하는 용도가 중심이다. 따라서 새 페이지의 기본 형식은 HTML 문서 모드로 바꾸고, 기존 Markdown 모드는 실제 사용 가능성이 낮으므로 관리자 UI에서 `일반 텍스트`로 명확히 표시한다. 페이지 대표 이미지는 공개 원문 HTML 응답과 연결되지 않으므로 제거하고, HTML 안에서 필요한 이미지는 기존 미디어 업로드 API로 파일을 올린 뒤 URL을 현재 커서 위치에 삽입하는 방식으로 정리한다. 미디어 사용 여부는 페이지 본문 문자열 안에 저장된 URL도 검사하므로, HTML 코드에 업로드 URL이 포함되면 페이지 사용처로 추적된다.

View File

@@ -79,8 +79,8 @@
|------|-----------|
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, HTML 문서 기본 모드, 일반 텍스트 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 일반 텍스트 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
@@ -125,11 +125,11 @@
|------|------|
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물 참여 지표) |
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 첫 번째 태그 색상 대표 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 제목 옆 댓글 수, 첫 번째 태그 색상 대표 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
| pages/admin/pages/index.vue | 페이지 목록, 화면 기준 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/index.vue | 페이지 목록, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 |
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
@@ -166,10 +166,10 @@
| 파일 | 기능 |
|------|------|
| server/api/posts.get.js | 게시물 목록 샘플 API |
| server/api/posts/[slug].get.js | 게시물 상세 샘플 API |
| server/api/pages.get.js | 고정 페이지 목록 샘플 API |
| server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API |
| server/api/posts.get.js | 공개 게시물 목록 API(`published`, 회원 세션 시 `members`) |
| server/api/posts/[slug].get.js | 공개 게시물 상세 API(`published`, 회원 세션 시 `members`) |
| server/api/pages.get.js | 공개 고정 페이지 목록 API(`published`만) |
| server/api/pages/[slug].get.js | 공개 고정 페이지 상세 API(`published`만) |
| server/api/tags.get.js | 태그 목록 샘플 API |
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
| server/api/site-settings.get.js | 공개 사이트 설정 API |
@@ -290,6 +290,7 @@
| db/migrations/033_site_settings_home_cover_dark_image.sql | 사이트 설정 다크모드 홈 커버 이미지 URL 컬럼 추가 |
| db/migrations/034_add_page_render_mode.sql | 고정 페이지 렌더링 모드 컬럼 추가 |
| db/migrations/035_default_pages_to_html_document.sql | 고정 페이지 렌더링 모드 기본값을 HTML 문서로 변경 |
| db/migrations/036_content_visibility_statuses.sql | 게시물 상태에 `members`/`private`, 페이지 상태에 `published`/`draft`/`private` 제약 추가 |
## 설정/배포

View File

@@ -262,12 +262,13 @@ components/content/
| canonical_url | String | canonical URL |
| noindex | Boolean | 검색엔진 노출 제외 여부 |
| og_image | String nullable | OG 이미지 |
| status | Enum | `published` / `draft`(예약은 `published` + 미래 `published_at`) |
| status | Enum | `published` / `draft` / `members` / `private`(예약은 `published` + 미래 `published_at`) |
| published_at | DateTime | 발행일 |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
> API 응답의 게시물 객체는 `isFeatured`와 `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
> 공개 게시물 목록·상세는 `published` 상태만 기본 노출하며, `members` 상태는 회원 세션이 있을 때만 노출한다. `private`와 `draft`는 공개 화면에서 노출하지 않는다.
### Users
@@ -316,6 +317,7 @@ components/content/
| content | Text | HTML 문서 원문 또는 일반 텍스트 콘텐츠 |
| render_mode | String | 렌더링 방식(`html_document`, `markdown`) |
| featured_image | String nullable | 레거시 컬럼, 관리자 페이지 작성 UI에서는 사용하지 않음 |
| status | Enum | `published` / `draft` / `private` |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
@@ -511,6 +513,7 @@ components/content/
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
> 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
> 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 현재 회원 세션이 있는 사용자에게만 공개하며, 등급별 제한은 후속 멤버십 등급 기능에서 확장한다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다.
> 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일).
> 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다.
> 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다.
@@ -560,10 +563,10 @@ components/content/
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태 문구, Preview, 상태별 주요 액션(Publish / Update·Unpublish / Update·Unschedule), 설정 패널 토글을 제공한다.
- 도구막대 상태 문구는 영어로 표시한다. **초안**: 편집 중 `Draft`, 저장(수동·서버 자동 저장) 진행 중 `Saving...`, 서버 기준과 동일할 때 `Draft - Saved`(신규 작성에서도 첫 `POST` 저장 후에는 `Draft - Saved`를 사용할 수 있다). **즉시 발행**: 공개 URL이 있으면 `Published ↗`를 링크로, 없으면 동일 문구만 표시한다. **예약 발행**: `Scheduled``#2BBA3C`·보통 굵기로 표시하고 마우스 오버 시 영문 한 줄로 예약 시각을 `title` 툴팁에 보여 준다.
- 도구막대 상태 문구는 영어로 표시한다. **초안**: 편집 중 `Draft`, 저장(수동·서버 자동 저장) 진행 중 `Saving...`, 서버 기준과 동일할 때 `Draft - Saved`(신규 작성에서도 첫 `POST` 저장 후에는 `Draft - Saved`를 사용할 수 있다). **즉시 발행**: 공개 URL이 있으면 `Published ↗`를 링크로, 없으면 동일 문구만 표시한다. **예약 발행**: `Scheduled``#2BBA3C`·보통 굵기로 표시하고 마우스 오버 시 영문 한 줄로 예약 시각을 `title` 툴팁에 보여 준다. **멤버십**은 `Members`, **비공개**는 `Private`로 표시한다.
- **초안**(예약 발행 제외)은 입력 변경 후 약 1.2초 디바운스로 서버 자동 저장을 호출한다(슬러그가 유효할 때만). 제목이 비어 있으면 DB/API 저장 시에만 `(제목 없음)` 플레이스홀더를 쓰고, 관리자 폼·목록 API 응답의 `title`은 빈 문자열로 내려 준다. 임시 슬러그(`d`+24자리 hex)는 제목을 직접 수정하기 전까지 제목 입력에 따라 슬러그가 따라가며, 사용자가 슬러그를 직접 고친 뒤에는 자동 연동하지 않는다. 신규 작성 화면 마운트 시 슬러그가 비어 있으면 임시 슬러그를 채운다. 기존 글은 `PUT /admin/api/posts/:id`, **신규 작성**은 첫 저장 시 `POST /admin/api/posts`로 행을 만든 뒤 `replace``/admin/posts/:id` 편집 화면으로 옮긴다. **이미 발행·예약으로 서버에 반영된 글**은 사이드바 등으로 폼만 초안처럼 바뀌어도 자동 저장하지 않으며, `Update`를 눌렀을 때만 `PUT`으로 반영한다(툴바의 `Publish`/`Update`/`Unpublish`/`Unschedule` 분기도 서버에 반영된 게시 형태를 기준으로 한다). 다른 화면으로 나가기 직전에는 디바운스 대기 중인 초안 변경이 있으면 타이머를 취소하고 한 번에 `POST` 또는 `PUT`으로 플러시한다.
- `Publish`는 **서버에 아직 초안으로만 저장된 글**에만 표시되며 클릭 시 전체 화면 발행 모달을 연다. 초안에서 연 모달의 기본 선택은 **발행·지금 바로**이다. 모달 본문은 뷰포트 세로 중앙에 가깝게 배치하고, 상단에는 제목·닫기만 둔다(도구막대의 `Preview` 버튼은 두지 않는다). 모달에서는 상태(발행/초안)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정하며, 예약 시각은 날짜·시간(KST 표기) 입력을 분리한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.
- `Update`는 발행·예약 글에 표시되며, 마지막 저장 이후 변경이 있을 때만 활성화된다(활성 텍스트 `#394047`, 비활성 `#8E9CAC`). `Publish`·활성화된 `Update`·`Unpublish`·`Unschedule`에는 호버 시 배경 `#f1f3f4`를 적용한다.
- `Update`는 발행·예약·멤버십·비공개 글에 표시되며, 마지막 저장 이후 변경이 있을 때만 활성화된다(활성 텍스트 `#394047`, 비활성 `#8E9CAC`). `Publish`·활성화된 `Update`·`Unpublish`·`Unschedule`에는 호버 시 배경 `#f1f3f4`를 적용한다.
- `Unpublish`·`Unschedule` 클릭 시 Ghost형 전체 화면 확인 화면을 연다. 발행·예약 시각 요약과 **발행 취소하고 초안으로 되돌리기 →**(또는 예약 취소 문구) 링크를 눌렀을 때만 `status`를 초안으로 되돌리고 `published_at`을 비운 뒤 `PUT`으로 저장한다.
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
@@ -640,11 +643,12 @@ components/content/
- 고정 페이지 작성/수정 화면의 기본 모드는 HTML 문서 모드이며, `markdown` 모드는 `일반 텍스트`로 표시한다.
- 고정 페이지 HTML 문서 모드는 전체 HTML 붙여넣기용 textarea를 사용하고, 공개 URL에서 Nuxt 레이아웃 없이 원문 HTML로 응답한다.
- 페이지 슬러그는 게시글처럼 한글 제목을 영문으로 로마자화해 자동 생성한다.
- 페이지 형식, Page URL, HTML 자산 업로드, 삭제 액션은 오른쪽 설정 패널에서 관리한다.
- 페이지 상태, 페이지 형식, Page URL, HTML 자산 업로드, 삭제 액션은 오른쪽 설정 패널에서 관리한다.
- HTML 자산 업로드는 기존 관리자 업로드 API(`/admin/api/uploads`)를 사용하며, 성공한 파일 URL을 HTML textarea 현재 커서 위치에 삽입한다. 업로드 파일은 현재 에디터 업로드 정책에 따라 `/uploads/posts/YYYY/MM/` 아래 저장되고 미디어 라이브러리 논리 폴더는 `미분류`로 기록된다.
- 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문을 저장한다. 대표 이미지는 페이지 작성 UI에서 사용하지 않는다.
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
- 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다.
- 고정 페이지 상태는 `published`, `draft`, `private`를 사용한다. 공개 목록·상세·HTML 문서 미들웨어는 `published` 상태만 응답하고, `draft``private`는 공개 URL에서 찾을 수 없는 페이지로 처리한다.
### 사이트 설정

View File

@@ -7,6 +7,7 @@
## 2차 관리자 개발
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
- [ ] 멤버십 글 공개 범위를 회원 등급별로 제한하는 권한 모델 추가. 현재 `members` 게시물은 로그인 회원 세션 기준으로만 공개됨
## 프론트엔드 개발

View File

@@ -1,5 +1,16 @@
# 업데이트 이력
## v1.5.4
- 게시물 상태: `멤버십`, `비공개` 상태 추가.
- 공개 게시물 API: 멤버십 글은 로그인 회원 세션이 있을 때만 조회되도록 수정.
- 공개 게시물 API: 비공개 글은 사용자 화면과 공개 API에서 숨기도록 정리.
- 관리자 글쓰기: 상태 선택·툴바·목록 필터에 멤버십/비공개 상태 반영.
- 고정 페이지 상태: `초안`, `공개`, `비공개` 상태 추가.
- 공개 페이지 API/HTML 문서 응답: 공개 상태 페이지에서만 접근되도록 수정.
- 관리자 페이지 작성/목록: 페이지 상태 선택과 목록 상태 표시 추가.
- DB: 게시물·페이지 공개 상태 제약을 확장하는 마이그레이션 추가.
## v1.5.3
- 관리자 페이지 작성/수정: 새 페이지 기본 형식을 HTML 문서 모드로 변경.

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.3",
"version": "1.5.4",
"private": true,
"type": "module",
"imports": {

View File

@@ -125,7 +125,7 @@ onBeforeUnmount(() => {
ref="pageForm"
:initial-page="page"
:saving="saving"
:can-view-page="Boolean(page?.slug)"
:can-view-page="Boolean(page?.slug) && page?.status === 'published'"
:public-url="publicPageUrl"
:deleting="deleting"
show-delete

View File

@@ -12,6 +12,40 @@ const { data: pages, refresh } = await useFetch('/admin/api/pages', {
default: () => []
})
/**
* 페이지 상태 표시 문자열 생성
* @param {Object} page - 페이지
* @returns {string} 상태 표시 문자열
*/
const getPageStatusLabel = (page) => {
if (page.status === 'draft') {
return '초안'
}
if (page.status === 'private') {
return '비공개'
}
return '공개'
}
/**
* 페이지 상태 텍스트 클래스 생성
* @param {Object} page - 페이지
* @returns {string} 상태 텍스트 클래스
*/
const getPageStatusClass = (page) => {
if (page.status === 'draft') {
return 'font-bold text-[#fb2d8d]'
}
if (page.status === 'private') {
return 'font-bold text-[#15171a]'
}
return 'text-[#99A3AD]'
}
/**
* 날짜 표시 형식 변환
* @param {string | null} value - ISO 날짜 문자열
@@ -83,6 +117,7 @@ const deletePage = async (page) => {
<thead class="admin-pages__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-pages__cell px-4 py-3">제목</th>
<th class="admin-pages__cell px-4 py-3">상태</th>
<th class="admin-pages__cell px-4 py-3">수정일</th>
<th class="admin-pages__cell admin-pages__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
@@ -99,6 +134,11 @@ const deletePage = async (page) => {
/pages/{{ page.slug }}
</p>
</td>
<td class="admin-pages__cell px-4 py-4">
<span class="admin-pages__status-text text-xs" :class="getPageStatusClass(page)">
{{ getPageStatusLabel(page) }}
</span>
</td>
<td class="admin-pages__cell px-4 py-4">
{{ formatDate(page.updatedAt) }}
</td>

View File

@@ -78,9 +78,13 @@ const isPublicPost = (post) => post.status === 'published'
/**
* 게시물 상태 필터 키 생성
* @param {Object} post - 게시물
* @returns {'published' | 'scheduled' | 'draft'} 상태 키
* @returns {'published' | 'scheduled' | 'draft' | 'members' | 'private'} 상태 키
*/
const getPostStatusKey = (post) => {
if (post.status === 'members' || post.status === 'private') {
return post.status
}
if (post.status === 'published' && !isPublicPost(post)) {
return 'scheduled'
}
@@ -108,6 +112,14 @@ const getPostStatusLabel = (post) => {
return '발행'
}
if (statusKey === 'members') {
return '멤버십'
}
if (statusKey === 'private') {
return '비공개'
}
return '초안'
}
@@ -127,6 +139,14 @@ const getPostStatusClass = (post) => {
return 'font-bold text-[#fb2d8d]'
}
if (statusKey === 'members') {
return 'font-bold text-[#5a63d8]'
}
if (statusKey === 'private') {
return 'font-bold text-[#15171a]'
}
if (statusKey === 'published') {
return 'text-[#99A3AD]'
}
@@ -411,6 +431,8 @@ watch(openPostMenuId, async (postId) => {
<option value="published">발행</option>
<option value="draft">초안</option>
<option value="scheduled">예약</option>
<option value="members">멤버십</option>
<option value="private">비공개</option>
</select>
</label>
<label class="admin-posts__filter">

View File

@@ -1,7 +1,11 @@
import { listPosts } from '../repositories/content-repository'
import { getMemberSession } from '../utils/member-auth'
/**
* 공개 게시물 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Array} 게시물 목록
*/
export default defineEventHandler(() => listPosts())
export default defineEventHandler((event) => listPosts({
includeMembers: Boolean(getMemberSession(event))
}))

View File

@@ -1,4 +1,5 @@
import { getPostBySlug } from '../../repositories/content-repository'
import { getMemberSession } from '../../utils/member-auth'
/**
* 공개 게시물 상세 API
@@ -7,7 +8,9 @@ import { getPostBySlug } from '../../repositories/content-repository'
*/
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
const post = await getPostBySlug(slug)
const post = await getPostBySlug(slug, {
includeMembers: Boolean(getMemberSession(event))
})
if (!post) {
throw createError({

View File

@@ -35,7 +35,7 @@ const mapPostRow = (row) => ({
canonicalUrl: row.canonical_url || '',
noindex: Boolean(row.noindex),
ogImage: row.og_image || null,
status: row.status === 'private' ? 'draft' : row.status,
status: row.status,
publishedAt: row.published_at ? row.published_at.toISOString() : null,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
@@ -63,6 +63,7 @@ const mapPageRow = (row) => ({
slug: row.slug,
content: row.content,
renderMode: row.render_mode || 'markdown',
status: row.status || 'published',
featuredImage: row.featured_image,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString()
@@ -210,9 +211,10 @@ const syncPostTags = async (sql, postId, tags) => {
/**
* 공개 게시물 목록 조회
* @param {{ includeMembers?: boolean }} [options] - 회원 전용 글 포함 여부
* @returns {Promise<Array>} 게시물 목록
*/
export const listPosts = async () => {
export const listPosts = async ({ includeMembers = false } = {}) => {
const sql = getPostgresClient()
if (!sql) {
@@ -232,9 +234,13 @@ export const listPosts = async () => {
FROM posts
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'
WHERE (
posts.status = 'published'
OR (${includeMembers} = true AND posts.status = 'members')
)
AND (
posts.published_at IS NULL
posts.status = 'members'
OR posts.published_at IS NULL
OR posts.published_at <= now()
)
GROUP BY posts.id
@@ -439,9 +445,10 @@ export const deleteAdminPost = async (id) => {
/**
* 공개 게시물 상세 조회
* @param {string} slug - 게시물 슬러그
* @param {{ includeMembers?: boolean }} [options] - 회원 전용 글 포함 여부
* @returns {Promise<Object | null>} 게시물 상세
*/
export const getPostBySlug = async (slug) => {
export const getPostBySlug = async (slug, { includeMembers = false } = {}) => {
const sql = getPostgresClient()
if (!sql) {
@@ -462,9 +469,13 @@ export const getPostBySlug = async (slug) => {
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE posts.slug = ${slug}
AND posts.status = 'published'
AND (
posts.published_at IS NULL
posts.status = 'published'
OR (${includeMembers} = true AND posts.status = 'members')
)
AND (
posts.status = 'members'
OR posts.published_at IS NULL
OR posts.published_at <= now()
)
GROUP BY posts.id
@@ -488,6 +499,7 @@ export const listPages = async () => {
const rows = await sql`
SELECT *
FROM pages
WHERE status = 'published'
ORDER BY created_at DESC
`
@@ -498,7 +510,21 @@ export const listPages = async () => {
* 관리자 고정 페이지 목록 조회
* @returns {Promise<Array>} 관리자 고정 페이지 목록
*/
export const listAdminPages = async () => listPages()
export const listAdminPages = async () => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePages()
}
const rows = await sql`
SELECT *
FROM pages
ORDER BY created_at DESC
`
return rows.map(mapPageRow)
}
/**
* 관리자 고정 페이지 상세 조회
@@ -540,14 +566,16 @@ export const createAdminPage = async (input) => {
slug,
content,
render_mode,
featured_image
featured_image,
status
)
VALUES (
${input.title},
${input.slug},
${input.content},
${input.renderMode},
${input.featuredImage}
${input.featuredImage},
${input.status}
)
RETURNING *
`
@@ -576,6 +604,7 @@ export const updateAdminPage = async (id, input) => {
content = ${input.content},
render_mode = ${input.renderMode},
featured_image = ${input.featuredImage},
status = ${input.status},
updated_at = now()
WHERE id = ${id}
RETURNING *
@@ -621,6 +650,7 @@ export const getPageBySlug = async (slug) => {
SELECT *
FROM pages
WHERE slug = ${slug}
AND status = 'published'
LIMIT 1
`

View File

@@ -1,10 +1,12 @@
import { z } from 'zod'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { pageStatusSchema } from './content-schema.js'
export const adminPageInputSchema = z.object({
title: z.string().trim().min(1),
slug: z.string().trim().min(1).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
renderMode: z.enum(['markdown', 'html_document']).default('html_document'),
status: pageStatusSchema.default('published'),
content: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null)
}).transform((input) => ({

View File

@@ -18,7 +18,7 @@ export const adminPostInputSchema = z.object({
canonicalUrl: z.string().trim().url().or(z.literal('')).default(''),
noindex: z.boolean().default(false),
ogImage: z.string().trim().nullable().default(null),
status: z.preprocess((val) => (val === 'private' ? 'draft' : val), postStatusSchema).default('draft'),
status: postStatusSchema.default('draft'),
publishedAt: z.string().datetime().nullable().default(null),
tags: z.array(z.string().trim().min(1)).default([])
})

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'
export const postStatusSchema = z.enum(['published', 'draft'])
export const postStatusSchema = z.enum(['published', 'draft', 'members', 'private'])
export const pageStatusSchema = z.enum(['published', 'draft', 'private'])
export const postSchema = z.object({
id: z.string().uuid(),
@@ -29,6 +30,7 @@ export const pageSchema = z.object({
slug: z.string().min(1),
content: z.string(),
renderMode: z.enum(['markdown', 'html_document']).default('markdown'),
status: pageStatusSchema.default('published'),
featuredImage: z.string().nullable().default(null),
createdAt: z.string(),
updatedAt: z.string()