관리자 글쓰기·목록 UX 개선 및 POST 설정 추가(v1.1.14~v1.1.18)

Ghost형 툴바·초안 자동 저장·발행 모달, private 제거, 미디어 모달 통합,
발행일·수정일 표시 설정과 DB 마이그레이션 025·026을 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-15 16:26:48 +09:00
parent ca1e17890b
commit 2074b0b93a
26 changed files with 1184 additions and 393 deletions

View File

@@ -5,6 +5,11 @@ const props = defineProps({
modelValue: { modelValue: {
type: [String, Array, Object], type: [String, Array, Object],
default: '' default: ''
},
/** 헤더 아래 고정 툴바 슬롯(AdminPostForm `#admin-post-form-editor-toolbar-host`) */
toolbarTeleportTo: {
type: String,
default: '#admin-post-form-editor-toolbar-host'
} }
}) })
@@ -22,20 +27,14 @@ const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false) const isLoadingMedia = ref(false)
const isUploading = ref(false) const isUploading = ref(false)
const mediaPickerTarget = ref('image') const mediaPickerTarget = ref('image')
const activeMediaPickerTab = ref('library')
const selectedMediaUrls = ref([]) const selectedMediaUrls = ref([])
const selectedImageWidth = ref('regular')
const lastSelectionState = ref({ const lastSelectionState = ref({
start: 0, start: 0,
end: 0, end: 0,
scrollTop: 0 scrollTop: 0
}) })
const imageWidthOptions = [
{ value: 'regular', label: '기본' },
{ value: 'wide', label: '와이드' },
{ value: 'full', label: '풀사이즈' }
]
const markdownValue = computed({ const markdownValue = computed({
get: () => normalizeMarkdownContent(props.modelValue), get: () => normalizeMarkdownContent(props.modelValue),
set: (value) => emit('update:modelValue', value) set: (value) => emit('update:modelValue', value)
@@ -500,12 +499,7 @@ const insertCodeBlock = () => {
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보 * @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
* @returns {string} 이미지 마크다운 * @returns {string} 이미지 마크다운
*/ */
const createImageMarkdown = (image) => { const createImageMarkdown = (image) => `![${image.alt || ''}](${image.url})`
const width = image.width && image.width !== 'regular'
? `{width=${image.width}}`
: ''
return `![${image.alt || ''}](${image.url})${width}`
}
/** /**
* 지정 줄 범위를 새 줄 목록으로 교체한다. * 지정 줄 범위를 새 줄 목록으로 교체한다.
@@ -680,6 +674,7 @@ const fetchMediaItems = async () => {
*/ */
const openMediaPicker = async (target) => { const openMediaPicker = async (target) => {
mediaPickerTarget.value = target mediaPickerTarget.value = target
activeMediaPickerTab.value = 'library'
selectedMediaUrls.value = [] selectedMediaUrls.value = []
isMediaPickerOpen.value = true isMediaPickerOpen.value = true
await fetchMediaItems() await fetchMediaItems()
@@ -692,6 +687,7 @@ const openMediaPicker = async (target) => {
const closeMediaPicker = () => { const closeMediaPicker = () => {
isMediaPickerOpen.value = false isMediaPickerOpen.value = false
selectedMediaUrls.value = [] selectedMediaUrls.value = []
activeMediaPickerTab.value = 'library'
} }
/** /**
@@ -724,8 +720,7 @@ const applyMediaSelection = () => {
if (item) { if (item) {
insertImage({ insertImage({
url: item.url, url: item.url,
alt: item.name || '', alt: item.name || ''
width: selectedImageWidth.value
}) })
} }
} else if (mediaPickerTarget.value === 'active-gallery') { } else if (mediaPickerTarget.value === 'active-gallery') {
@@ -779,8 +774,7 @@ const uploadAndInsert = async (files, target = 'image') => {
const uploadedFiles = await uploadImages(files) const uploadedFiles = await uploadImages(files)
const images = uploadedFiles.map((file) => ({ const images = uploadedFiles.map((file) => ({
url: file.url, url: file.url,
alt: file.name || '', alt: file.name || ''
width: target === 'image' ? selectedImageWidth.value : 'regular'
})) }))
if (target === 'gallery') { if (target === 'gallery') {
@@ -931,6 +925,59 @@ const handleFileInput = async (event, target) => {
event.target.value = '' event.target.value = ''
} }
/**
* 미디어 모달 업로드 탭에서 파일을 삽입한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {Promise<void>}
*/
const uploadFromMediaModal = async (files) => {
if (!files?.length) {
return
}
const target = mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery'
? 'gallery'
: 'image'
await uploadAndInsert(files, target)
closeMediaPicker()
}
/**
* 미디어 모달 업로드 영역에 파일을 드롭한다.
* @param {DragEvent} event - 드롭 이벤트
* @returns {Promise<void>}
*/
const handleMediaModalDrop = async (event) => {
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
if (!files.length) {
return
}
event.preventDefault()
await uploadFromMediaModal(files)
}
/**
* 미디어 모달의 삽입 대상 라벨을 반환한다.
* @returns {string} 모달 제목
*/
const mediaPickerTitle = computed(() => {
if (mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery') {
return '갤러리 이미지 선택'
}
return '이미지 선택'
})
/**
* 미디어 모달에서 다중 선택 여부를 반환한다.
* @returns {boolean} 다중 선택 여부
*/
const isGalleryMediaPicker = computed(() => mediaPickerTarget.value === 'gallery'
|| mediaPickerTarget.value === 'active-gallery')
/** /**
* 붙여넣은 이미지 파일을 업로드한다. * 붙여넣은 이미지 파일을 업로드한다.
* @param {ClipboardEvent} event - 붙여넣기 이벤트 * @param {ClipboardEvent} event - 붙여넣기 이벤트
@@ -1002,7 +1049,12 @@ const handleKeydown = (event) => {
<template> <template>
<div ref="editorRootRef" class="admin-markdown-editor grid gap-3"> <div ref="editorRootRef" class="admin-markdown-editor grid gap-3">
<div v-if="activeMode === 'write'" class="admin-markdown-editor__toolbar flex flex-wrap items-center gap-1.5 rounded border border-[#e3e6e8] bg-white p-2"> <Teleport :to="toolbarTeleportTo">
<div class="admin-markdown-editor__toolbar flex w-full min-h-[40px] items-center gap-1.5 border-b border-[#e3e6e8] bg-white px-8 py-1.5">
<div
v-show="activeMode === 'write'"
class="admin-markdown-editor__toolbar-tools flex min-w-0 flex-1 flex-wrap items-center gap-1.5"
>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(1)"> <button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(1)">
H1 H1
</button> </button>
@@ -1034,50 +1086,62 @@ const handleKeydown = (event) => {
구분선 구분선
</button> </button>
<span class="admin-markdown-editor__divider mx-1 h-6 w-px bg-[#e3e6e8]" aria-hidden="true" /> <span class="admin-markdown-editor__divider mx-1 h-6 w-px bg-[#e3e6e8]" aria-hidden="true" />
<select v-model="selectedImageWidth" class="admin-markdown-editor__image-width rounded border border-[#d7dde2] bg-white px-2 py-1.5 text-sm text-[#394047]">
<option v-for="option in imageWidthOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('image')"> <button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('image')">
미디어 이미지 이미지
</button> </button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('gallery')"> <button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('gallery')">
미디어 갤러리 갤러리
</button> </button>
<label class="admin-markdown-editor__upload cursor-pointer rounded bg-[#15171a] px-2.5 py-1.5 text-sm font-semibold text-white"> </div>
이미지 업로드
<input class="sr-only" type="file" accept="image/*" @change="handleFileInput($event, 'image')">
</label>
<label class="admin-markdown-editor__upload cursor-pointer rounded bg-[#15171a] px-2.5 py-1.5 text-sm font-semibold text-white">
갤러리 업로드
<input class="sr-only" type="file" accept="image/*" multiple @change="handleFileInput($event, 'gallery')">
</label>
<div class="admin-markdown-editor__mode ml-auto inline-flex rounded border border-[#d7dde2] bg-[#f6f7f8] p-0.5">
<button <button
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold" class="admin-markdown-editor__mode-toggle ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8e9cac]"
:class="activeMode === 'write' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'"
type="button" type="button"
@click="setEditorMode('write')" :aria-label="activeMode === 'write' ? '미리보기로 전환' : '작성 모드로 전환'"
@click="toggleEditorMode"
> >
작성 <svg
</button> v-if="activeMode === 'write'"
<button xmlns="http://www.w3.org/2000/svg"
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold" width="16"
:class="activeMode === 'preview' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'" height="16"
type="button" viewBox="0 0 24 24"
@click="setEditorMode('preview')" fill="none"
> stroke="currentColor"
미리보기 stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="admin-markdown-editor__mode-icon lucide-book-open"
aria-hidden="true"
>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="admin-markdown-editor__mode-icon lucide-edit-3"
aria-hidden="true"
>
<path d="M13 21h8" />
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
</svg>
</button> </button>
</div> </div>
</div> </Teleport>
<div v-if="activeMode === 'write'" class="admin-markdown-editor__write relative pl-12"> <div v-if="activeMode === 'write'" class="admin-markdown-editor__write relative">
<div class="admin-markdown-editor__editor-surface min-h-[620px]"> <div class="admin-markdown-editor__editor-surface min-h-[620px]">
<div <div
ref="gutterRef" ref="gutterRef"
class="admin-markdown-editor__gutter absolute bottom-0 left-0 top-0 w-10 select-none overflow-y-hidden overflow-x-hidden py-5 font-mono text-[13px] leading-7 text-[#a0a8b0]" class="admin-markdown-editor__gutter absolute bottom-0 left-[-40px] top-0 w-10 select-none overflow-y-hidden overflow-x-hidden py-5 font-mono text-[13px] leading-7 text-[#a0a8b0]"
aria-hidden="true" aria-hidden="true"
> >
<div <div
@@ -1091,7 +1155,7 @@ const handleKeydown = (event) => {
<textarea <textarea
ref="textareaRef" ref="textareaRef"
v-model="markdownValue" v-model="markdownValue"
class="admin-markdown-editor__textarea min-h-[620px] w-full resize-y border-0 bg-transparent py-5 pl-0 pr-5 font-mono text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:ring-0" class="admin-markdown-editor__textarea min-h-[620px] w-full resize-y border-0 bg-transparent py-5 pl-0 pr-5 text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:ring-0"
placeholder="마크다운으로 글을 작성하세요." placeholder="마크다운으로 글을 작성하세요."
spellcheck="false" spellcheck="false"
@keydown="handleKeydown" @keydown="handleKeydown"
@@ -1161,15 +1225,6 @@ const handleKeydown = (event) => {
> >
</label> </label>
<div class="admin-markdown-editor__media-editor-actions flex flex-wrap items-center gap-2"> <div class="admin-markdown-editor__media-editor-actions flex flex-wrap items-center gap-2">
<select
class="admin-markdown-editor__media-editor-select rounded border border-[#d7dde2] bg-white px-2 py-1.5 text-sm text-[#394047]"
:value="image.width"
@change="updateActiveMediaImage(imageIndex, { width: $event.target.value })"
>
<option v-for="option in imageWidthOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<button <button
v-if="activeMediaBlock.type === 'gallery'" v-if="activeMediaBlock.type === 'gallery'"
class="admin-markdown-editor__media-editor-action rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40" class="admin-markdown-editor__media-editor-action rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
@@ -1223,9 +1278,9 @@ const handleKeydown = (event) => {
<header class="admin-markdown-editor__media-header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4"> <header class="admin-markdown-editor__media-header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4">
<div> <div>
<h2 class="admin-markdown-editor__media-title text-lg font-bold text-black"> <h2 class="admin-markdown-editor__media-title text-lg font-bold text-black">
{{ mediaPickerTarget === 'image' ? '이미지 미디어 선택' : '갤러리 미디어 선택' }} {{ mediaPickerTitle }}
</h2> </h2>
<p class="admin-markdown-editor__media-count mt-1 text-sm text-[#6b7280]"> <p v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-count mt-1 text-sm text-[#6b7280]">
{{ selectedMediaUrls.length }} 선택됨 {{ selectedMediaUrls.length }} 선택됨
</p> </p>
</div> </div>
@@ -1234,7 +1289,27 @@ const handleKeydown = (event) => {
</button> </button>
</header> </header>
<div class="admin-markdown-editor__media-body overflow-y-auto p-5"> <div class="admin-markdown-editor__media-tabs flex border-b border-[#e3e6e8] px-5">
<button
class="admin-markdown-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'library'"
>
미디어 라이브러리
</button>
<button
class="admin-markdown-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'upload'"
>
업로드
</button>
</div>
<div class="admin-markdown-editor__media-body flex-1 overflow-y-auto p-5">
<template v-if="activeMediaPickerTab === 'library'">
<div v-if="isLoadingMedia" class="admin-markdown-editor__media-loading py-12 text-center text-sm text-[#8e9cac]"> <div v-if="isLoadingMedia" class="admin-markdown-editor__media-loading py-12 text-center text-sm text-[#8e9cac]">
불러오는 불러오는
</div> </div>
@@ -1253,12 +1328,41 @@ const handleKeydown = (event) => {
</span> </span>
</button> </button>
</div> </div>
<div v-else class="admin-markdown-editor__media-empty py-12 text-center text-sm text-[#8e9cac]"> <div v-else class="admin-markdown-editor__media-empty py-12 text-center text-sm text-[#8e9cac]">
선택할 미디어가 없습니다. 선택할 미디어가 없습니다. 업로드 탭에서 이미지를 추가할 있습니다.
</div>
</template>
<div
v-else
class="admin-markdown-editor__media-upload-zone grid min-h-[420px] place-items-center rounded border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
@dragover.prevent
@drop.prevent="handleMediaModalDrop"
>
<div class="admin-markdown-editor__media-upload-inner grid gap-3 px-6">
<p class="admin-markdown-editor__media-upload-title text-lg font-semibold text-[#15171a]">
파일을 끌어 업로드
</p>
<p class="admin-markdown-editor__media-upload-or text-sm text-[#6b7280]">
또는
</p>
<label class="admin-markdown-editor__media-upload-button mx-auto inline-flex h-10 cursor-pointer items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50">
{{ isUploading ? '업로드 중' : '파일 선택' }}
<input
class="sr-only"
type="file"
accept="image/*"
:multiple="isGalleryMediaPicker"
@change="uploadFromMediaModal($event.target.files); $event.target.value = ''"
>
</label>
<p class="admin-markdown-editor__media-upload-hint text-xs text-[#8e9cac]">
{{ isGalleryMediaPicker ? '여러 이미지를 한 번에 선택할 수 있습니다.' : '단일 이미지가 본문에 삽입됩니다.' }}
</p>
</div>
</div> </div>
</div> </div>
<footer class="admin-markdown-editor__media-footer flex items-center justify-end gap-2 border-t border-[#e3e6e8] px-5 py-4"> <footer v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-footer flex items-center justify-end gap-2 border-t border-[#e3e6e8] px-5 py-4">
<button class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker"> <button class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
취소 취소
</button> </button>

File diff suppressed because it is too large Load Diff

View File

@@ -74,12 +74,6 @@ const showAboutSection = false
</p> </p>
</div> </div>
</div> </div>
<form class="right-sidebar__subscribe mt-4 flex flex-col gap-2 sm:flex-row sm:items-stretch">
<input class="right-sidebar__input min-w-0 w-full flex-1 rounded-lg px-3 py-2 text-sm site-input sm:min-w-0" placeholder="Your email">
<button class="right-sidebar__button shrink-0 rounded-lg px-4 py-2 text-sm font-semibold site-button sm:self-auto" type="button">
Subscribe
</button>
</form>
</div> </div>
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0"> <div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">

View File

@@ -122,7 +122,7 @@ const navLinkClass = (url) => {
:class="isExpanded(node.id) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'" :class="isExpanded(node.id) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
> >
<div class="sidebar-primary-nav-list__sub-clip min-h-0 overflow-hidden"> <div class="sidebar-primary-nav-list__sub-clip min-h-0 overflow-hidden">
<ul class="sidebar-primary-nav-list__sub ml-0 mt-2 pl-3 pt-0"> <ul class="sidebar-primary-nav-list__sub ml-0 mt-1 pl-3 pt-0">
<SidebarPrimaryNavList :nodes="node.children" /> <SidebarPrimaryNavList :nodes="node.children" />
</ul> </ul>
</div> </div>

View File

@@ -20,3 +20,50 @@ export function formatPostDate(value) {
return `${year}.${month}.${day}` return `${year}.${month}.${day}`
} }
/**
* 관리자·상세 메타용 날짜·시각을 YYYY.MM.DD 오전/오후 HH:MM 형식으로 변환한다.
* @param {string | null | undefined} value - ISO 8601 등 파싱 가능한 날짜 문자열
* @returns {string} 빈 문자열 또는 포맷된 날짜·시각
*/
export function formatPostDateTime(value) {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = date.getHours()
const minutes = String(date.getMinutes()).padStart(2, '0')
const period = hours < 12 ? '오전' : '오후'
const hour12 = String(hours % 12 || 12).padStart(2, '0')
return `${year}.${month}.${day} ${period} ${hour12}:${minutes}`
}
/**
* 발행 이후 본문·메타 수정이 있었는지 판별한다(동일 초 갱신은 제외).
* @param {{ publishedAt?: string | null, updatedAt?: string | null }} post - 게시물
* @returns {boolean} 발행 후 수정 여부
*/
export function wasPostUpdatedAfterPublish(post) {
if (!post?.publishedAt || !post?.updatedAt) {
return false
}
const publishedMs = new Date(post.publishedAt).getTime()
const updatedMs = new Date(post.updatedAt).getTime()
if (Number.isNaN(publishedMs) || Number.isNaN(updatedMs)) {
return false
}
return updatedMs - publishedMs > 60_000
}

View File

@@ -0,0 +1,10 @@
-- 비공개(private) 상태를 초안으로 통합하고 CHECK 제약을 published/draft만 허용하도록 변경한다.
UPDATE posts
SET status = 'draft'
WHERE status = 'private';
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_status_check;
ALTER TABLE posts
ADD CONSTRAINT posts_status_check CHECK (status IN ('published', 'draft'));

View File

@@ -0,0 +1,2 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS show_post_updated_at BOOLEAN NOT NULL DEFAULT false;

View File

@@ -1,5 +1,22 @@
# 업데이트 요약 # 업데이트 요약
## v1.1.18
- 마크다운 에디터 이미지·갤러리 삽입을 단일 모달(미디어 라이브러리·업로드 탭)로 통합하고, 이미지 너비 툴바를 제거했다.
- POST 설정에서 발행 후 수정일 표시를 켜고 끌 수 있으며, 관리자 글 목록·공개 상세에 반영된다.
## v1.1.16
- 게시 상태를 초안·발행·예약만 쓰도록 정리하고, 신규 초안 임시 슬러그·발행 UI·툴바 저장 동작을 맞췄다.
## v1.1.15
- 신규 초안 서버 자동 저장, 초안 이탈 확인 모달 제거, 글 목록 헤더 필터 배치를 정리했다.
## v1.1.14
- 관리자 글쓰기 상단을 Ghost에 가깝게 바꾸고, 초안 자동 저장은 서버 PUT만 쓰며 발행·예약 글은 Update로만 저장되게 정리했다.
## v1.1.13 ## v1.1.13
- 상단 메뉴 깊이를 한 단계로 제한하고, 추천 사이트를 DB·관리자 탭·우측 Recommended 카드(외부 파비콘 프록시)로 연결했다. - 상단 메뉴 깊이를 한 단계로 제한하고, 추천 사이트를 DB·관리자 탭·우측 Recommended 카드(외부 파비콘 프록시)로 연결했다.

View File

@@ -140,6 +140,8 @@ docker compose --env-file .env.production up -d --build
# 기존 운영 DB를 유지한 채 새 버전을 올릴 때 추천 글·네비 location 마이그레이션 적용 # 기존 운영 DB를 유지한 채 새 버전을 올릴 때 추천 글·네비 location 마이그레이션 적용
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/023_add_post_featured.sql docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/023_add_post_featured.sql
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/024_navigation_recommended_location.sql docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/024_navigation_recommended_location.sql
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/025_posts_status_no_private.sql
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/026_site_settings_show_post_updated_at.sql
``` ```
### Docker 네트워크 충돌 대응 ### Docker 네트워크 충돌 대응

View File

@@ -1,5 +1,29 @@
# 의사결정 이력 # 의사결정 이력
## 2026-05-15 v1.1.18
### 에디터 미디어 UX·발행일·수정일 표시 설정
이미지 삽입을 미디어/업로드 버튼으로 나누면 워드프레스 대비 단계가 많아지므로, 툴바는 `이미지`·`갤러리`만 두고 모달에서 라이브러리(기본)와 업로드 탭을 전환한다. 본문 이미지 너비 옵션은 렌더·운영 복잡도 대비 사용 빈도가 낮아 툴바·블록 편집 UI에서 제거한다(기존 `{width=…}` 마크다운은 파싱만 유지). 발행일은 관리자 목록에서 시·분까지 보여 주고, 발행 후 수정이 있으면 `site_settings.show_post_updated_at`이 true일 때만 보조 줄로 수정 시각을 노출해 공개 상세와 동일한 정책을 쓴다.
## 2026-05-15 v1.1.16
### 게시물 상태 단순화·초안 첫 저장·발행 글 편집 UX
접근 권한별 공개 범위는 추후 별도 기능으로 두고, 지금은 `private`를 없애 모두 초안·발행(`published_at`으로 예약 여부 판별)만 쓴다. 제목 없이 저장이 막히던 문제는 신규에 임시 슬러그와 서버 측 빈 제목 `(제목 없음)` 보정으로 푼다. 이미 발행된 글에서 사이드바만 바꾸면 툴바가 `Publish`로 바뀌던 것은 폼 상태만 본 탓이므로, 서버에 반영된 게시 형태를 따로 두어 `Update`·자동 저장 여부를 맞춘다.
## 2026-05-15 v1.1.15
### 초안 서버 자동 저장·이탈 가드·목록 헤더
신규 글도 초안·비공개면 DB 행 없이 입력만 두면 복구가 불가능하므로 기존 글과 동일하게 디바운스 `POST`로 첫 행을 만든 뒤 편집 URL로 옮긴다. 초안·비공개는 이후에도 자동 저장되므로 내부 이동 시 미저장 확인 모달은 발행·예약처럼 자동 저장이 없는 경우에만 둔다. 디바운스 직후 이탈로 놓치는 한 번의 변경은 라우트 가드에서 타이머를 취소하고 즉시 `POST`/`PUT` 플러시로 보완한다. 글 목록은 Ghost류처럼 필터를 «새 글» 왼쪽에 붙여 한 눈에 조작 단위를 모은다.
## 2026-05-15 v1.1.14
### 관리자 글쓰기 툴바·저장 정책을 Ghost에 맞춤
로컬 자동 저장 복원은 저장된 DB 초안과 새 글 작성 UX가 충돌한다. 초안은 서버 디바운스 저장으로 `Draft`/`Saving...`/`Draft - Saved`를 맞추고, 발행·예약 글은 자동 저장 없이 `Update` 한 번에만 반영한다. 툴바는 상태별 `Publish`·`Update`·`Unpublish`·`Unschedule``Published ↗`·`Scheduled` 툴팁으로 의도를 분명히 한다.
## 2026-05-15 v1.1.13 ## 2026-05-15 v1.1.13
### 상단 메뉴 1뎁스·추천 사이트·파비콘 프록시 ### 상단 메뉴 1뎁스·추천 사이트·파비콘 프록시

View File

@@ -16,7 +16,7 @@
| 파일 | 용도 | | 파일 | 용도 |
|------|------| |------|------|
| composables/formatPostDate.js | 공개 화면 게시일 `YYYY.MM.DD` 포맷 | | composables/formatPostDate.js | 공개 게시일 `YYYY.MM.DD`, 관리자·수정일 보조 `formatPostDateTime`, `wasPostUpdatedAfterPublish` |
| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) | | composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) |
## 공유 라이브러리(서버·클라이언트 공통) ## 공유 라이브러리(서버·클라이언트 공통)
@@ -65,9 +65,9 @@
| 파일 | 화면 위치 | | 파일 | 화면 위치 |
|------|-----------| |------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 제목 입력 text-3xl, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 추천 글 토글, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 | | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글 등 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 | | components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, Enter 새 문단·Shift+Enter 백슬래시 hard break 줄바꿈 입력, 작성 모드 왼쪽 바깥 absolute 논리 줄 번호 거터·거터 스크롤 동기화·스크롤바 숨김, 작성/미리보기 전환(`Cmd/Ctrl+E`)과 커서 복원, 작성 모드 툴바 마크다운 삽입, 미리보기 모드 툴바 숨김, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 | | components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 툴바 `이미지`·`갤러리` + 미디어 모달(라이브러리 기본·업로드 탭), 현재 이미지·갤러리 편집 패널(너비 UI 없음) |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) | | components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
@@ -107,9 +107,9 @@
|------|------| |------|------|
| 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 | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 | | pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 | | pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) | | pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
| pages/admin/pages/index.vue | 페이지 목록 | | pages/admin/pages/index.vue | 페이지 목록 |
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 | | pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
@@ -119,7 +119,7 @@
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·일반 강등, 일반 태그 검색/메인 전환/삭제, 일반 태그 헤더의 태그 추가 버튼), 액션 피드백 토스트 | | pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·일반 강등, 일반 태그 검색/메인 전환/삭제, 일반 태그 헤더의 태그 추가 버튼), 액션 피드백 토스트 |
| pages/admin/tags/new.vue | 태그 생성 | | pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 | | pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면: 좌측 검색·내비와 우측 본문을 **중앙 `max-w` 래퍼**에 묶고 본문은 **약 760px** 상한, 우측 상단 **고정 닫기**, 밝은 회색 배경·본문 열 흰색, 블로그 제목·설명은 읽기 전용·`편집` 시 입력·저장/취소, 기타(로고·URL·저작권) 저장, 타임존·어나운스·Import/Export·스팸 플레이스홀더 | | pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 수정일 표시 토글·저장), 블로그 제목·설명·기타(로고·URL·저작권), 타임존·어나운스·Import/Export·스팸 플레이스홀더 |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) | | pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) | | pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) | | pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |

View File

@@ -73,6 +73,7 @@
- API의 ISO 8601 `publishedAt`를 공개 UI에서는 로컬 날짜 기준 `YYYY.MM.DD`로 표시한다. - API의 ISO 8601 `publishedAt`를 공개 UI에서는 로컬 날짜 기준 `YYYY.MM.DD`로 표시한다.
- 변환은 `composables/formatPostDate.js``formatPostDate`를 사용한다. - 변환은 `composables/formatPostDate.js``formatPostDate`를 사용한다.
- 관리자 목록·수정일 보조 라벨은 `formatPostDateTime`(`YYYY.MM.DD 오전/오후 HH:MM`)을 사용한다. 발행 후 수정 여부는 `wasPostUpdatedAfterPublish`로 판별하며, `site_settings.show_post_updated_at`이 true일 때만 「수정: …」를 노출한다.
- `<time>`에는 표시용 문자열과 함께 가능한 경우 원본 시각을 `datetime` 속성으로 둔다. - `<time>`에는 표시용 문자열과 함께 가능한 경우 원본 시각을 `datetime` 속성으로 둔다.
### Page 페이지 ### Page 페이지
@@ -227,7 +228,7 @@ components/content/
| canonical_url | String | canonical URL | | canonical_url | String | canonical URL |
| noindex | Boolean | 검색엔진 노출 제외 여부 | | noindex | Boolean | 검색엔진 노출 제외 여부 |
| og_image | String nullable | OG 이미지 | | og_image | String nullable | OG 이미지 |
| status | Enum | published/draft/private | | status | Enum | `published` / `draft`(예약은 `published` + 미래 `published_at`) |
| published_at | DateTime | 발행일 | | published_at | DateTime | 발행일 |
| created_at | DateTime | 생성일 | | created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 | | updated_at | DateTime | 수정일 |
@@ -432,8 +433,9 @@ components/content/
- `PUT /admin/api/tags/:id` - 태그 수정 - `PUT /admin/api/tags/:id` - 태그 수정
- `DELETE /admin/api/tags/:id` - 태그 삭제 - `DELETE /admin/api/tags/:id` - 태그 삭제
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장 - `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
- `GET /admin/api/settings` - 사이트 설정 조회 - `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt` 포함)
- `PUT /admin/api/settings` - 사이트 설정 수정 - `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`: 발행 후 수정일 보조 표시 여부)
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt` 포함)
- `GET /admin/api/navigation` - 네비게이션 항목 목록 - `GET /admin/api/navigation` - 네비게이션 항목 목록
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장 - `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함) - `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
@@ -443,8 +445,10 @@ components/content/
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭) - `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`) - `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다. > 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다. > 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
> 관리자 글 목록 상단은 좌측에 제목 블록, 우측에 상태·태그·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다.
> 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다. > 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다. > 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다. > 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
@@ -472,16 +476,19 @@ components/content/
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다. - `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다. - 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다. - 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
- 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url){width=...}` 형식으로 삽입한다. - 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
- 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url)` 형식으로 삽입한다.
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다. - 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
- 이미지 너비 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
- 작성 모드에서 커서가 이미지 마크다운 줄 또는 `:::gallery` 블록 안에 있으면 현재 미디어 블록 편집 패널을 표시한다. - 작성 모드에서 커서가 이미지 마크다운 줄 또는 `:::gallery` 블록 안에 있으면 현재 미디어 블록 편집 패널을 표시한다.
- 현재 미디어 블록 편집 패널은 alt, URL, 너비 값을 수정하고 갤러리 이미지 순서 변경, 삭제, 미디어 라이브러리 이미지 추가를 지원한다. - 현재 미디어 블록 편집 패널은 alt, URL을 수정하고 갤러리 이미지 순서 변경, 삭제, 미디어 라이브러리 이미지 추가를 지원한다.
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다. - 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다. - 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다. - 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태 문구, Preview, 상태별 주요 액션(Publish / Update·Unpublish / Update·Unschedule), 설정 패널 토글을 제공한다.
- 글 작성/수정 화면의 저장 버튼은 즉시 저장하지 않고, 전체 화면 발행 모달에서 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다. - 도구막대 상태 문구는 영어로 표시한다. **초안**: 편집 중 `Draft`, 저장(수동·서버 자동 저장) 진행 중 `Saving...`, 서버 기준과 동일할 때 `Draft - Saved`(신규 작성에서도 첫 `POST` 저장 후에는 `Draft - Saved`를 사용할 수 있다). **즉시 발행**: 공개 URL이 있으면 `Published ↗`를 링크로, 없으면 동일 문구만 표시한다. **예약 발행**: `Scheduled``#2BBA3C`·보통 굵기로 표시하고 마우스 오버 시 영문 한 줄로 예약 시각을 `title` 툴팁에 보여 준다.
- 글 작성/수정 화면의 저장 버튼은 현재 입력값이 마지막 저장 기준점과 다를 때만 활성화한다. - **초안**(예약 발행 제외)은 입력 변경 후 약 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`를 적용한다.
- `Unpublish`·`Unschedule` 클릭 시 Ghost형 전체 화면 확인 화면을 연다. 발행·예약 시각 요약과 **발행 취소하고 초안으로 되돌리기 →**(또는 예약 취소 문구) 링크를 눌렀을 때만 `status`를 초안으로 되돌리고 `published_at`을 비운 뒤 `PUT`으로 저장한다.
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다. - 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다. - 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다. - 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
@@ -494,10 +501,6 @@ components/content/
- 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다. - 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다. - 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다. - 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
- 자동 저장본이 있으면 상단 툴바의 상태 문구 옆에서 복원 또는 무시(로컬 초안 삭제)를 선택할 수 있다.
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다. - 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다. - 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
- 미리보기 본문·헤로 영역은 공개 상세와 동일하게 중앙 `max-w-[720px]` 컬럼과 `px-4 sm:px-5` 수평 패딩을 적용한다. - 미리보기 본문·헤로 영역은 공개 상세와 동일하게 중앙 `max-w-[720px]` 컬럼과 `px-4 sm:px-5` 수평 패딩을 적용한다.
@@ -555,6 +558,7 @@ components/content/
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존·어나운스 바·게시물 Import/Export·스팸 필터는 현재 **메뉴·안내 카드만** 제공하고 저장 API는 연결하지 않는다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. - 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존·어나운스 바·게시물 Import/Export·스팸 필터는 현재 **메뉴·안내 카드만** 제공하고 저장 API는 연결하지 않는다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다.
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다. - 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다. - 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- **POST 설정**(`show_post_updated_at`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록·공개 글 상세에 수정 시각 보조 줄을 표시할지 여부.
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp``/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다. - 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp``/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다. - 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다. - 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
@@ -588,7 +592,7 @@ components/content/
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다. - 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다. - 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다. - 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
- 관리자 게시글 작성/수정과 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 해당 로컬 자동 저장본은 삭제한다. - 관리자 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 관리자 게시글 작성/수정 화면은 **서버에 이미 즉시 발행 또는 예약으로 저장된 글**에서 미저장 변경이 있을 때만 동일 방식으로 내부 이동을 막고, 초안(서버 기준)은 서버 자동 저장과 라우트 이탈 직전 플러시로 처리하여 해당 경우에는 모달을 쓰지 않는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 레거시 키 `SORI_ADMIN_POST_AUTOSAVE:*`가 있으면 삭제한다.
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다. - `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다. - 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다.
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다. - 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.

View File

@@ -1,5 +1,38 @@
# 업데이트 이력 # 업데이트 이력
## v1.1.18
- 마크다운 에디터: 이미지 너비 선택 제거, 툴바 `이미지`·`갤러리` 단일 버튼 + 미디어 모달(라이브러리 기본·업로드 탭).
- 사이트 설정 `showPostUpdatedAt`(POST 설정 > 수정일 표시), 마이그레이션 `026_site_settings_show_post_updated_at.sql`.
- 관리자 글 목록 발행일 시·분 표시, 설정 ON 시 발행 후 수정 줄 보조 표시. 공개 글 상세 동일.
- `formatPostDateTime`, `wasPostUpdatedAfterPublish` 추가.
- 패키지 버전 `1.1.18`로 갱신.
## v1.1.17
- 관리자 글: 제목 없음은 DB/API만 플레이스홀더, 폼·목록은 빈 제목·임시 슬러그는 제목 입력 시 자동 슬러그.
- 초안 Publish 모달 기본값 발행, Unpublish/Unschedule 확인 화면 추가, 발행·예약 시각 날짜/시간 분리 입력.
- 패키지 버전 `1.1.17`로 갱신.
## v1.1.16
- 게시물 `private` 제거: DB `025_posts_status_no_private.sql`, API·폼·목록을 `published`/`draft`+예약 시각만 사용.
- 신규 글: 마운트 시 `d`+24hex 임시 슬러그, 빈 제목은 저장 시 `(제목 없음)` 보정.
- 발행·예약 글: 사이드바만 바꿔도 자동 저장하지 않음, 툴바는 서버 반영 상태 기준으로 `Update` 유지. 발행 모달 세로 중앙·도구막대 `Preview` 제거.
- 패키지 버전 `1.1.16`으로 갱신.
## v1.1.15
- 관리자 신규 글: 초안·비공개 시 `POST` 디바운스 자동 저장 후 편집 URL로 교체, 라우트 이탈 직전 미전송 초안 플러시.
- 관리자 글 편집: 미저장 이탈 모달은 즉시 발행·예약 미저장에만 적용(초안·비공개는 제외).
- 관리자 글 목록: 헤더 한 줄에 필터를 «새 글» 왼쪽에 배치.
- 패키지 버전 `1.1.15`로 갱신.
## v1.1.14
- 관리자 글쓰기: Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule·색·호버), 초안만 서버 디바운스 자동 저장, 발행·예약은 Update로만 반영, 로컬 자동 저장 복원 UI 제거.
- 패키지 버전 `1.1.14`로 갱신.
## v1.1.13 ## v1.1.13
- 상단 네비: 하위 1뎁스만 허용(서버 검증·공개 트리 조립·관리자 드래그·이미 하위가 있는 항목의 하위 편입 금지). - 상단 네비: 하위 1뎁스만 허용(서버 검증·공개 트리 조립·관리자 드래그·이미 하위가 있는 항목의 하위 편입 금지).

41
lib/admin-post-title.js Normal file
View File

@@ -0,0 +1,41 @@
/** @type {string} DB/API에만 쓰는 제목 없음 표시(폼·목록에는 빈 문자열로 노출) */
export const ADMIN_POST_PLACEHOLDER_TITLE = '(제목 없음)'
/**
* 제목 없음 플레이스홀더 여부
* @param {string | null | undefined} title - 제목
* @returns {boolean}
*/
export const isAdminPostPlaceholderTitle = (title) => {
const t = String(title ?? '').trim()
return !t || t === ADMIN_POST_PLACEHOLDER_TITLE
}
/**
* 관리자 폼·목록에 표시할 제목(플레이스홀더는 빈 문자열)
* @param {string | null | undefined} title - DB/API 제목
* @returns {string}
*/
export const toAdminPostFormTitle = (title) => {
if (isAdminPostPlaceholderTitle(title)) {
return ''
}
return String(title).trim()
}
/**
* API 저장용 제목(비어 있으면 플레이스홀더)
* @param {string | null | undefined} title - 폼 제목
* @returns {string}
*/
export const toAdminPostStoredTitle = (title) => {
const t = String(title ?? '').trim()
return t.length ? t : ADMIN_POST_PLACEHOLDER_TITLE
}
/**
* 임시 슬러그 여부(d + 24~25자리 hex)
* @param {string | null | undefined} slug - 슬러그
* @returns {boolean}
*/
export const isAdminPostDraftPlaceholderSlug = (slug) => /^d[0-9a-f]{24,25}$/i.test(String(slug ?? '').trim())

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "1.1.13", "version": "1.1.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sori.studio", "name": "sori.studio",
"version": "1.1.13", "version": "1.1.18",
"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": "1.1.13", "version": "1.1.18",
"private": true, "private": true,
"type": "module", "type": "module",
"imports": { "imports": {

View File

@@ -6,6 +6,7 @@ definePageMeta({
const route = useRoute() const route = useRoute()
const id = computed(() => String(route.params.id || '')) const id = computed(() => String(route.params.id || ''))
const saving = ref(false) const saving = ref(false)
const autoSaving = ref(false)
const deleting = ref(false) const deleting = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const postForm = ref(null) const postForm = ref(null)
@@ -106,6 +107,43 @@ const savePost = async (payload) => {
} }
} }
/**
* 초안 자동 저장(디바운스 PUT) — 발행·예약 글에는 사용하지 않는다.
* @param {Object} payload - `createPostPayload`와 동일 구조
* @returns {Promise<void>}
*/
const autosaveDraftPost = async (payload) => {
if (!id.value || autoSaving.value) {
return
}
autoSaving.value = true
try {
const updatedPost = await $fetch(`/admin/api/posts/${id.value}`, {
method: 'PUT',
body: payload
})
post.value = updatedPost
postForm.value?.markSaved()
} catch (error) {
const msg = error?.data?.message || error?.message || '자동 저장에 실패했습니다.'
showToast('error', msg)
} finally {
autoSaving.value = false
}
}
onBeforeRouteLeave(async () => {
await nextTick()
const payload = postForm.value?.takePendingAutosavePayload?.()
if (!payload) {
return true
}
await autosaveDraftPost(payload)
return true
})
/** /**
* 게시물 삭제 * 게시물 삭제
* @returns {Promise<void>} 삭제 처리 결과 * @returns {Promise<void>} 삭제 처리 결과
@@ -148,13 +186,14 @@ onBeforeUnmount(() => {
<AdminPostForm <AdminPostForm
ref="postForm" ref="postForm"
:initial-post="post" :initial-post="post"
submit-label="변경 저장"
:saving="saving" :saving="saving"
:auto-saving="autoSaving"
:can-view-post="isPublicPost(post)" :can-view-post="isPublicPost(post)"
:public-url="publicPostUrl" :public-url="publicPostUrl"
:deleting="deleting" :deleting="deleting"
show-delete show-delete
@submit="savePost" @submit="savePost"
@autosave="autosaveDraftPost"
@preview="previewPost" @preview="previewPost"
@delete="deletePost" @delete="deletePost"
/> />

View File

@@ -17,22 +17,34 @@ const { data: tags } = await useFetch('/admin/api/tags', {
default: () => [] default: () => []
}) })
const { data: siteSettings } = await useFetch('/admin/api/settings', {
default: () => ({ showPostUpdatedAt: false })
})
/** /**
* 날짜 표시 형식 변환 * 게시물 발행일 라벨을 반환한다.
* @param {string | null} value - ISO 날짜 문자열 * @param {Object} post - 게시물
* @returns {string} 화면 표시 날짜 * @returns {string} 발행일 또는 '-'
*/ */
const formatDate = (value) => { const getPublishDateLabel = (post) => {
if (!value) { if (!post.publishedAt) {
return '-' return '-'
} }
const date = new Date(value) return formatPostDateTime(post.publishedAt)
const year = date.getFullYear() }
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}.${month}.${day}` /**
* 게시물 수정일 보조 라벨을 반환한다.
* @param {Object} post - 게시물
* @returns {string} 수정일 라벨 또는 빈 문자열
*/
const getUpdatedDateLabel = (post) => {
if (!siteSettings.value?.showPostUpdatedAt || !wasPostUpdatedAfterPublish(post)) {
return ''
}
return `수정: ${formatPostDateTime(post.updatedAt)}`
} }
/** /**
@@ -46,7 +58,7 @@ const isPublicPost = (post) => post.status === 'published'
/** /**
* 게시물 상태 필터 키 생성 * 게시물 상태 필터 키 생성
* @param {Object} post - 게시물 * @param {Object} post - 게시물
* @returns {'published' | 'scheduled' | 'draft' | 'private'} 상태 키 * @returns {'published' | 'scheduled' | 'draft'} 상태 키
*/ */
const getPostStatusKey = (post) => { const getPostStatusKey = (post) => {
if (post.status === 'published' && !isPublicPost(post)) { if (post.status === 'published' && !isPublicPost(post)) {
@@ -57,10 +69,6 @@ const getPostStatusKey = (post) => {
return 'published' return 'published'
} }
if (post.status === 'private') {
return 'private'
}
return 'draft' return 'draft'
} }
@@ -80,10 +88,6 @@ const getPostStatusLabel = (post) => {
return '발행' return '발행'
} }
if (statusKey === 'private') {
return '비공개'
}
return '초안' return '초안'
} }
@@ -171,7 +175,7 @@ const deletePost = async (post) => {
<template> <template>
<section class="admin-posts bg-paper p-6"> <section class="admin-posts bg-paper p-6">
<div class="admin-posts__header flex items-center justify-between gap-4"> <div class="admin-posts__header flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<p class="admin-posts__eyebrow text-xs font-semibold uppercase text-muted"> <p class="admin-posts__eyebrow text-xs font-semibold uppercase text-muted">
Posts Posts
@@ -180,37 +184,38 @@ const deletePost = async (post) => {
목록 목록
</h1> </h1>
</div> </div>
<NuxtLink class="admin-posts__new rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/posts/new"> <div class="admin-posts__header-actions flex min-w-0 flex-1 flex-wrap items-center justify-start gap-2 lg:flex-nowrap lg:justify-end">
<div class="admin-posts__filters flex min-w-0 flex-wrap items-center gap-2">
</NuxtLink> <label class="admin-posts__filter">
</div> <span class="sr-only">상태 필터</span>
<select v-model="statusFilter" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
<div class="admin-posts__filters mt-6 flex flex-wrap items-center gap-2"> <option value="all">전체 상태</option>
<label class="admin-posts__filter"> <option value="published">발행</option>
<span class="sr-only">상태 필터</span> <option value="draft">초안</option>
<select v-model="statusFilter" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"> <option value="scheduled">예약</option>
<option value="all">전체 상태</option> </select>
<option value="published">발행</option> </label>
<option value="draft">초안</option> <label class="admin-posts__filter">
<option value="scheduled">예약</option> <span class="sr-only">태그 필터</span>
</select> <select v-model="tagFilter" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
</label> <option value="all">전체 태그</option>
<label class="admin-posts__filter"> <option v-for="tag in usedTagSlugs" :key="tag" :value="tag">
<span class="sr-only">태그 필터</span> {{ getTagName(tag) }}
<select v-model="tagFilter" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"> </option>
<option value="all">전체 태그</option> </select>
<option v-for="tag in usedTagSlugs" :key="tag" :value="tag"> </label>
{{ getTagName(tag) }} <label class="admin-posts__filter">
</option> <span class="sr-only">정렬</span>
</select> <select v-model="sortOrder" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
</label> <option value="newest">최신순</option>
<label class="admin-posts__filter"> <option value="oldest">오래된순</option>
<span class="sr-only">정렬</span> </select>
<select v-model="sortOrder" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"> </label>
<option value="newest">최신순</option> </div>
<option value="oldest">오래된순</option> <NuxtLink class="admin-posts__new shrink-0 rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/posts/new">
</select>
</label> </NuxtLink>
</div>
</div> </div>
<p v-if="errorMessage" class="admin-posts__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <p v-if="errorMessage" class="admin-posts__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
@@ -224,7 +229,7 @@ const deletePost = async (post) => {
<th class="admin-posts__cell px-4 py-3">제목</th> <th class="admin-posts__cell px-4 py-3">제목</th>
<th class="admin-posts__cell px-4 py-3">상태</th> <th class="admin-posts__cell px-4 py-3">상태</th>
<th class="admin-posts__cell px-4 py-3">태그</th> <th class="admin-posts__cell px-4 py-3">태그</th>
<th class="admin-posts__cell px-4 py-3">수정</th> <th class="admin-posts__cell px-4 py-3">발행</th>
<th class="admin-posts__cell px-4 py-3">관리</th> <th class="admin-posts__cell px-4 py-3">관리</th>
</tr> </tr>
</thead> </thead>
@@ -250,9 +255,6 @@ const deletePost = async (post) => {
> >
{{ getPostStatusLabel(post) }} {{ getPostStatusLabel(post) }}
</span> </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">
<div v-if="post.tags.length" class="admin-posts__tag-list flex flex-wrap gap-1.5"> <div v-if="post.tags.length" class="admin-posts__tag-list flex flex-wrap gap-1.5">
@@ -267,7 +269,12 @@ const deletePost = async (post) => {
<span v-else class="admin-posts__tag-empty text-muted">-</span> <span v-else class="admin-posts__tag-empty text-muted">-</span>
</td> </td>
<td class="admin-posts__cell px-4 py-4"> <td class="admin-posts__cell px-4 py-4">
{{ formatDate(post.updatedAt) }} <p class="admin-posts__publish-date text-[#394047]">
{{ getPublishDateLabel(post) }}
</p>
<p v-if="getUpdatedDateLabel(post)" class="admin-posts__updated-date mt-1 text-xs text-muted">
{{ getUpdatedDateLabel(post) }}
</p>
</td> </td>
<td class="admin-posts__cell px-4 py-4"> <td class="admin-posts__cell px-4 py-4">
<button <button

View File

@@ -4,6 +4,7 @@ definePageMeta({
}) })
const saving = ref(false) const saving = ref(false)
const autoSaving = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const postForm = ref(null) const postForm = ref(null)
const toast = ref(null) const toast = ref(null)
@@ -68,6 +69,52 @@ const savePost = async (payload) => {
} }
} }
/**
* 신규 초안 서버 자동 저장(POST). 성공 시 기본은 편집 URL로 교체 이동, 이탈 플러시 시에는 이동하지 않는다.
* @param {Object} payload - 게시물 입력값
* @param {{ skipNavigate?: boolean }} options - `skipNavigate`가 true면 생성만 하고 URL은 바꾸지 않는다.
* @returns {Promise<void>}
*/
const createDraftAutosave = async (payload, options = {}) => {
const skipNavigate = Boolean(options.skipNavigate)
if (autoSaving.value) {
return
}
autoSaving.value = true
errorMessage.value = ''
try {
const post = await $fetch('/admin/api/posts', {
method: 'POST',
body: payload
})
postForm.value?.markSaved()
postForm.value?.clearAutosave()
postForm.value?.allowNextRouteLeave()
if (!skipNavigate) {
await navigateTo(`/admin/posts/${post.id}`, { replace: true })
}
} catch (error) {
const msg = error?.data?.message || error?.message || '자동 저장에 실패했습니다.'
errorMessage.value = msg
showToast('error', msg)
} finally {
autoSaving.value = false
}
}
onBeforeRouteLeave(async () => {
await nextTick()
const payload = postForm.value?.takePendingAutosavePayload?.()
if (!payload) {
return true
}
await createDraftAutosave(payload, { skipNavigate: true })
return true
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.clearTimeout(toastTimer) window.clearTimeout(toastTimer)
}) })
@@ -78,7 +125,14 @@ onBeforeUnmount(() => {
<p v-if="errorMessage" class="admin-post-editor__error mx-6 mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <p v-if="errorMessage" class="admin-post-editor__error mx-6 mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<AdminPostForm ref="postForm" submit-label=" 저장" :saving="saving" @submit="savePost" @preview="previewPost" /> <AdminPostForm
ref="postForm"
:saving="saving"
:auto-saving="autoSaving"
@submit="savePost"
@autosave="createDraftAutosave"
@preview="previewPost"
/>
<div <div
v-if="toast" v-if="toast"
class="admin-post-editor__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg" class="admin-post-editor__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"

View File

@@ -33,7 +33,8 @@ const form = reactive({
logoText: settings.value?.logoText || '井', logoText: settings.value?.logoText || '井',
logoUrl: settings.value?.logoUrl || '', logoUrl: settings.value?.logoUrl || '',
faviconUrl: settings.value?.faviconUrl || '', faviconUrl: settings.value?.faviconUrl || '',
copyrightText: settings.value?.copyrightText || '©2026 sori.studio' copyrightText: settings.value?.copyrightText || '©2026 sori.studio',
showPostUpdatedAt: Boolean(settings.value?.showPostUpdatedAt)
}) })
/** /**
@@ -49,6 +50,12 @@ const settingsNavGroups = [
{ id: 'admin-settings-section-misc', label: '기타 설정', keywords: 'logo url copyright favicon' } { id: 'admin-settings-section-misc', label: '기타 설정', keywords: 'logo url copyright favicon' }
] ]
}, },
{
heading: '게시물',
items: [
{ id: 'admin-settings-section-post', label: 'POST 설정', keywords: 'post updated date display 수정일' }
]
},
{ {
heading: '사이트', heading: '사이트',
items: [ items: [
@@ -242,7 +249,8 @@ const buildSiteSettingsPayload = () => ({
logoText: form.logoText || '井', logoText: form.logoText || '井',
logoUrl: form.logoUrl, logoUrl: form.logoUrl,
faviconUrl: form.faviconUrl, faviconUrl: form.faviconUrl,
copyrightText: form.copyrightText copyrightText: form.copyrightText,
showPostUpdatedAt: Boolean(form.showPostUpdatedAt)
}) })
/** /**
@@ -667,6 +675,44 @@ onBeforeUnmount(() => {
</div> </div>
</section> </section>
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
게시물
</h2>
<section
id="admin-settings-section-post"
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<div class="admin-settings-screen__card-head mb-4">
<h2 class="text-lg font-semibold text-[#15171a]">
POST 설정
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
공개 상세·관리자 목록에서 발행 수정이 있었을 수정일을 함께 표시합니다.
</p>
</div>
<label class="admin-settings-screen__toggle flex cursor-pointer items-center justify-between gap-4 rounded-lg border border-[#e6e8eb] bg-[#f7f8fa] px-4 py-4">
<span class="grid gap-1">
<span class="text-sm font-semibold text-[#15171a]">수정일 표시</span>
<span class="text-xs text-[#657080]">발행일 아래에 수정: YYYY.MM.DD 오전/오후 HH:MM 형식으로 노출</span>
</span>
<input
v-model="form.showPostUpdatedAt"
class="admin-settings-screen__toggle-input size-5 shrink-0 accent-[#15171a]"
type="checkbox"
>
</label>
<div class="admin-settings-screen__actions mt-6 flex justify-end border-t border-[#eceff2] pt-6">
<button
class="rounded-md bg-[#15171a] px-5 py-2.5 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
type="button"
:disabled="saving"
@click="persistSiteSettings({ successToast: 'POST 설정이 저장되었습니다.' })"
>
{{ saving ? '저장 중' : 'POST 설정 저장' }}
</button>
</div>
</section>
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]"> <h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
사이트 사이트
</h2> </h2>

View File

@@ -13,6 +13,9 @@ const { data: tags } = await useFetch('/api/tags', {
const { data: posts } = await useFetch('/api/posts', { const { data: posts } = await useFetch('/api/posts', {
default: () => [] default: () => []
}) })
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({ showPostUpdatedAt: false })
})
if (!post.value) { if (!post.value) {
throw createError({ throw createError({
@@ -43,6 +46,13 @@ const primaryTagMeta = computed(() => {
}) })
const publishedAtLabel = computed(() => formatPostDate(post.value.publishedAt || null)) const publishedAtLabel = computed(() => formatPostDate(post.value.publishedAt || null))
const updatedAtLabel = computed(() => {
if (!siteSettings.value?.showPostUpdatedAt || !wasPostUpdatedAfterPublish(post.value)) {
return ''
}
return `수정: ${formatPostDateTime(post.value.updatedAt)}`
})
const authorLabel = computed(() => 'sori.studio') const authorLabel = computed(() => 'sori.studio')
const shareModalOpen = ref(false) const shareModalOpen = ref(false)
const copyButtonLabel = ref('Copy link') const copyButtonLabel = ref('Copy link')
@@ -234,6 +244,10 @@ useHead(() => ({
{{ publishedAtLabel }} {{ publishedAtLabel }}
</time> </time>
<time v-if="updatedAtLabel" :datetime="post.updatedAt" class="admin-post-detail__updated-at">
{{ updatedAtLabel }}
</time>
<a href="#" class="hover:opacity-75"> <a href="#" class="hover:opacity-75">
{{ authorLabel }} {{ authorLabel }}
</a> </a>

View File

@@ -8,6 +8,7 @@ import {
import { getDefaultNavigationItems } from '../utils/navigation-items' import { getDefaultNavigationItems } from '../utils/navigation-items'
import { buildPublicPrimaryTree, orderNavigationItemsForInsert } from '../utils/navigation-tree' import { buildPublicPrimaryTree, orderNavigationItemsForInsert } from '../utils/navigation-tree'
import { getDefaultSiteSettings } from '../utils/site-settings' import { getDefaultSiteSettings } from '../utils/site-settings'
import { toAdminPostFormTitle } from '../../lib/admin-post-title.js'
import { getPostgresClient } from './postgres-client' import { getPostgresClient } from './postgres-client'
/** /**
@@ -29,13 +30,23 @@ const mapPostRow = (row) => ({
canonicalUrl: row.canonical_url || '', canonicalUrl: row.canonical_url || '',
noindex: Boolean(row.noindex), noindex: Boolean(row.noindex),
ogImage: row.og_image || null, ogImage: row.og_image || null,
status: row.status, status: row.status === 'private' ? 'draft' : row.status,
publishedAt: row.published_at ? row.published_at.toISOString() : null, publishedAt: row.published_at ? row.published_at.toISOString() : null,
createdAt: row.created_at.toISOString(), createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(), updatedAt: row.updated_at.toISOString(),
tags: row.tags || [] tags: row.tags || []
}) })
/**
* 관리자 API용 게시물 행 변환(제목 없음 플레이스홀더는 빈 문자열)
* @param {Object} row - 게시물 행
* @returns {Object} 게시물 응답
*/
const mapAdminPostRow = (row) => ({
...mapPostRow(row),
title: toAdminPostFormTitle(row.title)
})
/** /**
* 고정 페이지 행을 API 응답 구조로 변환 * 고정 페이지 행을 API 응답 구조로 변환
* @param {Object} row - 고정 페이지 행 * @param {Object} row - 고정 페이지 행
@@ -82,6 +93,7 @@ const mapSiteSettingsRow = (row) => ({
logoUrl: row.logo_url || '', logoUrl: row.logo_url || '',
faviconUrl: row.favicon_url || '', faviconUrl: row.favicon_url || '',
copyrightText: row.copyright_text, copyrightText: row.copyright_text,
showPostUpdatedAt: Boolean(row.show_post_updated_at),
updatedAt: row.updated_at.toISOString() updatedAt: row.updated_at.toISOString()
}) })
@@ -245,7 +257,7 @@ export const listAdminPosts = async () => {
ORDER BY posts.updated_at DESC ORDER BY posts.updated_at DESC
` `
return rows.map(mapPostRow) return rows.map(mapAdminPostRow)
} }
/** /**
@@ -278,7 +290,7 @@ export const getAdminPostById = async (id) => {
LIMIT 1 LIMIT 1
` `
return rows[0] ? mapPostRow(rows[0]) : null return rows[0] ? mapAdminPostRow(rows[0]) : null
} }
/** /**
@@ -797,6 +809,7 @@ export const updateSiteSettings = async (input) => {
logo_url, logo_url,
favicon_url, favicon_url,
copyright_text, copyright_text,
show_post_updated_at,
updated_at updated_at
) )
VALUES ( VALUES (
@@ -808,6 +821,7 @@ export const updateSiteSettings = async (input) => {
${input.logoUrl || ''}, ${input.logoUrl || ''},
${input.faviconUrl || ''}, ${input.faviconUrl || ''},
${input.copyrightText}, ${input.copyrightText},
${input.showPostUpdatedAt ? true : false},
now() now()
) )
ON CONFLICT (id) DO UPDATE ON CONFLICT (id) DO UPDATE
@@ -819,6 +833,7 @@ export const updateSiteSettings = async (input) => {
logo_url = EXCLUDED.logo_url, logo_url = EXCLUDED.logo_url,
favicon_url = EXCLUDED.favicon_url, favicon_url = EXCLUDED.favicon_url,
copyright_text = EXCLUDED.copyright_text, copyright_text = EXCLUDED.copyright_text,
show_post_updated_at = EXCLUDED.show_post_updated_at,
updated_at = now() updated_at = now()
RETURNING * RETURNING *
` `

View File

@@ -1,9 +1,13 @@
import { z } from 'zod' import { z } from 'zod'
import { ADMIN_POST_PLACEHOLDER_TITLE } from '../../lib/admin-post-title.js'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js' import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { postStatusSchema } from './content-schema.js' import { postStatusSchema } from './content-schema.js'
export const adminPostInputSchema = z.object({ export const adminPostInputSchema = z.object({
title: z.string().trim().min(1), title: z.preprocess((val) => {
const t = String(val ?? '').trim()
return t.length ? t : ADMIN_POST_PLACEHOLDER_TITLE
}, z.string().min(1)),
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/), slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
content: z.preprocess(normalizeMarkdownContent, z.string()).default(''), content: z.preprocess(normalizeMarkdownContent, z.string()).default(''),
excerpt: z.string().default(''), excerpt: z.string().default(''),
@@ -14,7 +18,7 @@ export const adminPostInputSchema = z.object({
canonicalUrl: z.string().trim().url().or(z.literal('')).default(''), canonicalUrl: z.string().trim().url().or(z.literal('')).default(''),
noindex: z.boolean().default(false), noindex: z.boolean().default(false),
ogImage: z.string().trim().nullable().default(null), ogImage: z.string().trim().nullable().default(null),
status: postStatusSchema.default('draft'), status: z.preprocess((val) => (val === 'private' ? 'draft' : val), postStatusSchema).default('draft'),
publishedAt: z.string().datetime().nullable().default(null), publishedAt: z.string().datetime().nullable().default(null),
tags: z.array(z.string().trim().min(1)).default([]) tags: z.array(z.string().trim().min(1)).default([])
}) })

View File

@@ -7,7 +7,8 @@ export const adminSiteSettingsInputSchema = z.object({
logoText: z.string().trim().max(8).optional().default('井'), logoText: z.string().trim().max(8).optional().default('井'),
logoUrl: z.string().trim().max(500).optional().default(''), logoUrl: z.string().trim().max(500).optional().default(''),
faviconUrl: z.string().trim().max(500).optional().default(''), faviconUrl: z.string().trim().max(500).optional().default(''),
copyrightText: z.string().trim().min(1) copyrightText: z.string().trim().min(1),
showPostUpdatedAt: z.boolean().optional().default(false)
}) })
/** /**

View File

@@ -1,6 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
export const postStatusSchema = z.enum(['published', 'draft', 'private']) export const postStatusSchema = z.enum(['published', 'draft'])
export const postSchema = z.object({ export const postSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),

View File

@@ -14,6 +14,7 @@ export const getDefaultSiteSettings = () => {
logoUrl: '', logoUrl: '',
faviconUrl: '', faviconUrl: '',
copyrightText: `©${new Date().getFullYear()} ${title}`, copyrightText: `©${new Date().getFullYear()} ${title}`,
showPostUpdatedAt: false,
updatedAt: null updatedAt: null
} }
} }