메뉴 관리 기능 추가

This commit is contained in:
2026-05-02 16:45:52 +09:00
parent 27cf05aba6
commit 04b8a7006a
18 changed files with 497 additions and 37 deletions

View File

@@ -0,0 +1,7 @@
import { getPublicNavigation } from '../repositories/content-repository'
/**
* 공개 네비게이션 API
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 네비게이션 항목
*/
export default defineEventHandler(() => getPublicNavigation())

View File

@@ -5,6 +5,7 @@ import {
getSamplePosts,
getSampleTags
} from '../utils/sample-content'
import { getDefaultNavigationItems, groupNavigationItems } from '../utils/navigation-items'
import { getDefaultSiteSettings } from '../utils/site-settings'
import { getPostgresClient } from './postgres-client'
@@ -70,6 +71,22 @@ const mapSiteSettingsRow = (row) => ({
updatedAt: row.updated_at.toISOString()
})
/**
* 네비게이션 행을 API 응답 구조로 변환
* @param {Object} row - 네비게이션 행
* @returns {Object} 네비게이션 응답
*/
const mapNavigationItemRow = (row) => ({
id: row.id,
label: row.label,
url: row.url,
location: row.location,
sortOrder: row.sort_order,
isVisible: row.is_visible,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString()
})
/**
* 태그 슬러그 목록 정규화
* @param {Array<string>} tags - 태그 슬러그 목록
@@ -569,6 +586,82 @@ export const updateSiteSettings = async (input) => {
return mapSiteSettingsRow(rows[0])
}
/**
* 네비게이션 항목 목록 조회
* @param {Object} options - 조회 옵션
* @param {boolean} options.visibleOnly - 표시 항목만 조회할지 여부
* @returns {Promise<Array>} 네비게이션 항목 목록
*/
export const listNavigationItems = async ({ visibleOnly = false } = {}) => {
const sql = getPostgresClient()
if (!sql) {
return getDefaultNavigationItems()
.filter((item) => !visibleOnly || item.isVisible)
}
const rows = visibleOnly
? await sql`
SELECT *
FROM navigation_items
WHERE is_visible = true
ORDER BY location ASC, sort_order ASC, label ASC
`
: await sql`
SELECT *
FROM navigation_items
ORDER BY location ASC, sort_order ASC, label ASC
`
return rows.map(mapNavigationItemRow)
}
/**
* 공개 네비게이션 조회
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 공개 네비게이션
*/
export const getPublicNavigation = async () => groupNavigationItems(await listNavigationItems({ visibleOnly: true }))
/**
* 관리자 네비게이션 항목 일괄 저장
* @param {Array<Object>} items - 저장할 네비게이션 항목 목록
* @returns {Promise<Array>} 저장된 네비게이션 항목 목록
*/
export const updateNavigationItems = async (items) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
await sql.begin(async (transaction) => {
await transaction`
DELETE FROM navigation_items
`
for (const item of items) {
await transaction`
INSERT INTO navigation_items (
label,
url,
location,
sort_order,
is_visible
)
VALUES (
${item.label},
${item.url},
${item.location},
${item.sortOrder},
${item.isVisible}
)
`
}
})
return listNavigationItems()
}
/**
* 관리자 태그 상세 조회
* @param {string} id - 태그 ID

View File

@@ -0,0 +1,13 @@
import { requireAdminSession } from '../../../utils/admin-auth'
import { listNavigationItems } from '../../../repositories/content-repository'
/**
* 관리자 네비게이션 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array>} 네비게이션 항목 목록
*/
export default defineEventHandler((event) => {
requireAdminSession(event)
return listNavigationItems()
})

View File

@@ -0,0 +1,24 @@
import { createError, readBody } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth'
import { parseAdminNavigationInput } from '../../../utils/admin-navigation-input'
import { updateNavigationItems } from '../../../repositories/content-repository'
/**
* 관리자 네비게이션 일괄 저장 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array>} 저장된 네비게이션 항목 목록
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const parsedBody = parseAdminNavigationInput(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '네비게이션 입력 형식이 올바르지 않습니다.'
})
}
return updateNavigationItems(parsedBody.data.items)
})

View File

@@ -0,0 +1,21 @@
import { z } from 'zod'
export const adminNavigationItemInputSchema = z.object({
id: z.string().optional().nullable(),
label: z.string().trim().min(1),
url: z.string().trim().min(1).regex(/^(\/|https?:\/\/)/),
location: z.enum(['primary', 'footer']),
sortOrder: z.coerce.number().int().min(0).default(0),
isVisible: z.boolean().default(true)
})
export const adminNavigationInputSchema = z.object({
items: z.array(adminNavigationItemInputSchema)
})
/**
* 관리자 네비게이션 입력값 정리
* @param {unknown} body - 요청 본문
* @returns {import('zod').SafeParseReturnType<unknown, Object>} 검증 결과
*/
export const parseAdminNavigationInput = (body) => adminNavigationInputSchema.safeParse(body)

View File

@@ -0,0 +1,26 @@
/**
* 기본 네비게이션 항목 반환
* @returns {Array<Object>} 기본 네비게이션 항목
*/
export const getDefaultNavigationItems = () => [
{ id: 'default-primary-home', label: 'Home pages', url: '/', location: 'primary', sortOrder: 10, isVisible: true },
{ id: 'default-primary-tags', label: 'Tags', url: '/tags', location: 'primary', sortOrder: 20, isVisible: true },
{ id: 'default-primary-authors', label: 'Authors', url: '/pages/about', location: 'primary', sortOrder: 30, isVisible: true },
{ id: 'default-primary-style', label: 'Style', url: '/post/hello-sori-studio', location: 'primary', sortOrder: 40, isVisible: true },
{ id: 'default-primary-post-types', label: 'Post types', url: '/post/custom-writing-tool', location: 'primary', sortOrder: 50, isVisible: true },
{ id: 'default-primary-members', label: 'Members', url: '/pages/contact', location: 'primary', sortOrder: 60, isVisible: true },
{ id: 'default-primary-landing', label: 'Landing pages', url: '/pages/projects', location: 'primary', sortOrder: 70, isVisible: true },
{ id: 'default-footer-portal', label: 'Portal', url: '/pages/links', location: 'footer', sortOrder: 10, isVisible: true },
{ id: 'default-footer-docs', label: 'Docs', url: '/pages/about', location: 'footer', sortOrder: 20, isVisible: true },
{ id: 'default-footer-projects', label: 'Projects', url: '/pages/projects', location: 'footer', sortOrder: 30, isVisible: true }
]
/**
* 네비게이션 항목을 위치별로 묶기
* @param {Array<Object>} items - 네비게이션 항목 목록
* @returns {{primary: Array<Object>, footer: Array<Object>}} 위치별 네비게이션 항목
*/
export const groupNavigationItems = (items) => ({
primary: items.filter((item) => item.location === 'primary'),
footer: items.filter((item) => item.location === 'footer')
})