관리자 글쓰기·목록 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

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

View File

@@ -1,9 +1,13 @@
import { z } from 'zod'
import { ADMIN_POST_PLACEHOLDER_TITLE } from '../../lib/admin-post-title.js'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { postStatusSchema } from './content-schema.js'
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가-힣]+)*$/),
content: z.preprocess(normalizeMarkdownContent, 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(''),
noindex: z.boolean().default(false),
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),
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('井'),
logoUrl: 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'
export const postStatusSchema = z.enum(['published', 'draft', 'private'])
export const postStatusSchema = z.enum(['published', 'draft'])
export const postSchema = z.object({
id: z.string().uuid(),

View File

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