메뉴 관리: 탭 분리·드래그 정렬·상단 트리·공개 접기 (v0.0.94)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,6 @@ import { getPublicNavigation } from '../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공개 네비게이션 API
|
||||
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 네비게이션 항목
|
||||
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} `primary`는 트리(`children` 선택), `footer`는 평면
|
||||
*/
|
||||
export default defineEventHandler(() => getPublicNavigation())
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
getSamplePosts,
|
||||
getSampleTags
|
||||
} from '../utils/sample-content'
|
||||
import { getDefaultNavigationItems, groupNavigationItems } from '../utils/navigation-items'
|
||||
import { getDefaultNavigationItems } from '../utils/navigation-items'
|
||||
import { buildPublicPrimaryTree, orderNavigationItemsForInsert } from '../utils/navigation-tree'
|
||||
import { getDefaultSiteSettings } from '../utils/site-settings'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
|
||||
@@ -89,6 +90,8 @@ const mapNavigationItemRow = (row) => ({
|
||||
location: row.location,
|
||||
sortOrder: row.sort_order,
|
||||
isVisible: row.is_visible,
|
||||
parentId: row.parent_id ?? null,
|
||||
isFolder: Boolean(row.is_folder),
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString()
|
||||
})
|
||||
@@ -806,7 +809,23 @@ export const listNavigationItems = async ({ visibleOnly = false } = {}) => {
|
||||
* 공개 네비게이션 조회
|
||||
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 공개 네비게이션
|
||||
*/
|
||||
export const getPublicNavigation = async () => groupNavigationItems(await listNavigationItems({ visibleOnly: true }))
|
||||
export const getPublicNavigation = async () => {
|
||||
const flat = await listNavigationItems({ visibleOnly: true })
|
||||
const primaryFlat = flat.filter((item) => item.location === 'primary')
|
||||
const footerFlat = flat
|
||||
.filter((item) => item.location === 'footer' && !item.parentId)
|
||||
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||
|
||||
return {
|
||||
primary: buildPublicPrimaryTree(primaryFlat),
|
||||
footer: footerFlat.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
isVisible: item.isVisible
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 네비게이션 항목 일괄 저장
|
||||
@@ -825,21 +844,28 @@ export const updateNavigationItems = async (items) => {
|
||||
DELETE FROM navigation_items
|
||||
`
|
||||
|
||||
for (const item of items) {
|
||||
const ordered = orderNavigationItemsForInsert(items)
|
||||
for (const item of ordered) {
|
||||
await transaction`
|
||||
INSERT INTO navigation_items (
|
||||
id,
|
||||
label,
|
||||
url,
|
||||
location,
|
||||
sort_order,
|
||||
is_visible
|
||||
is_visible,
|
||||
parent_id,
|
||||
is_folder
|
||||
)
|
||||
VALUES (
|
||||
${item.id},
|
||||
${item.label},
|
||||
${item.url},
|
||||
${item.location},
|
||||
${item.sortOrder},
|
||||
${item.isVisible}
|
||||
${item.isVisible},
|
||||
${item.parentId ?? null},
|
||||
${Boolean(item.isFolder)}
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createError, readBody } from 'h3'
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { parseAdminNavigationInput } from '../../../utils/admin-navigation-input'
|
||||
import { updateNavigationItems } from '../../../repositories/content-repository'
|
||||
import { renumberSortOrderByTree, validateNavigationItems } from '../../../utils/navigation-tree'
|
||||
|
||||
/**
|
||||
* 관리자 네비게이션 일괄 저장 API
|
||||
@@ -20,5 +21,27 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
return updateNavigationItems(parsedBody.data.items)
|
||||
const items = parsedBody.data.items.map((row) => ({
|
||||
id: row.id,
|
||||
label: row.label.trim(),
|
||||
url: row.url.trim(),
|
||||
location: row.location,
|
||||
sortOrder: row.sortOrder,
|
||||
isVisible: row.isVisible,
|
||||
parentId: row.parentId ?? null,
|
||||
isFolder: Boolean(row.isFolder)
|
||||
}))
|
||||
|
||||
const checked = validateNavigationItems(items)
|
||||
if (!checked.ok) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: checked.message
|
||||
})
|
||||
}
|
||||
|
||||
renumberSortOrderByTree(items, 'primary')
|
||||
renumberSortOrderByTree(items, 'footer')
|
||||
|
||||
return updateNavigationItems(items)
|
||||
})
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const adminNavigationItemInputSchema = z.object({
|
||||
id: z.string().optional().nullable(),
|
||||
id: z.string().uuid(),
|
||||
label: z.string().trim().min(1),
|
||||
url: z.string().trim().min(1).regex(/^(\/|https?:\/\/)/),
|
||||
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)
|
||||
isVisible: z.boolean().default(true),
|
||||
parentId: z.union([z.string().uuid(), z.null()]).optional(),
|
||||
isFolder: z.boolean().default(false)
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.location === 'footer' && data.parentId) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['parentId'],
|
||||
message: '하단 메뉴는 하위 항목을 가질 수 없습니다.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const adminNavigationInputSchema = z.object({
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
/**
|
||||
* 기본 네비게이션 항목 반환
|
||||
* 기본 네비게이션 항목 반환(DB 없을 때)
|
||||
* id는 UUID 형식이어야 관리자 저장 검증과 맞는다.
|
||||
* @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 }
|
||||
{ id: 'a0000001-0001-4001-8001-000000000001', label: 'Home pages', url: '/', location: 'primary', sortOrder: 10, isVisible: true, parentId: null, isFolder: false },
|
||||
{ id: 'a0000001-0001-4001-8001-000000000002', label: 'Tags', url: '/tags', location: 'primary', sortOrder: 20, isVisible: true, parentId: null, isFolder: false },
|
||||
{ id: 'a0000001-0001-4001-8001-000000000003', label: 'Authors', url: '/pages/about', location: 'primary', sortOrder: 30, isVisible: true, parentId: null, isFolder: false },
|
||||
{ id: 'a0000001-0001-4001-8001-000000000004', label: 'Style', url: '/post/hello-sori-studio', location: 'primary', sortOrder: 40, isVisible: true, parentId: null, isFolder: false },
|
||||
{ id: 'a0000001-0001-4001-8001-000000000005', label: 'Post types', url: '/post/custom-writing-tool', location: 'primary', sortOrder: 50, isVisible: true, parentId: null, isFolder: false },
|
||||
{ id: 'a0000001-0001-4001-8001-000000000006', label: 'Members', url: '/pages/contact', location: 'primary', sortOrder: 60, isVisible: true, parentId: null, isFolder: false },
|
||||
{ id: 'a0000001-0001-4001-8001-000000000007', label: 'Landing pages', url: '/pages/projects', location: 'primary', sortOrder: 70, isVisible: true, parentId: null, isFolder: false },
|
||||
{ id: 'a0000002-0002-4002-8002-000000000001', label: 'Portal', url: '/pages/links', location: 'footer', sortOrder: 10, isVisible: true, parentId: null, isFolder: false },
|
||||
{ id: 'a0000002-0002-4002-8002-000000000002', label: 'Docs', url: '/pages/about', location: 'footer', sortOrder: 20, isVisible: true, parentId: null, isFolder: false },
|
||||
{ id: 'a0000002-0002-4002-8002-000000000003', label: 'Projects', url: '/pages/projects', location: 'footer', sortOrder: 30, isVisible: true, parentId: null, isFolder: false }
|
||||
]
|
||||
|
||||
/**
|
||||
* 네비게이션 항목을 위치별로 묶기
|
||||
* @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')
|
||||
})
|
||||
|
||||
230
server/utils/navigation-tree.js
Normal file
230
server/utils/navigation-tree.js
Normal file
@@ -0,0 +1,230 @@
|
||||
import { buildNavigationEditorTree } from '../../lib/navigation-editor-tree.js'
|
||||
|
||||
/**
|
||||
* 네비게이션 항목 배열이 유효한지 검증한다.
|
||||
* @param {Array<Object>} items - { id, label, url, location, sortOrder, isVisible, isFolder, parentId }
|
||||
* @returns {{ ok: true } | { ok: false, message: string }}
|
||||
*/
|
||||
export const validateNavigationItems = (items) => {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const idSet = new Set()
|
||||
for (const item of items) {
|
||||
const id = String(item.id || '').trim()
|
||||
if (!id) {
|
||||
return { ok: false, message: '네비게이션 항목 id가 비어 있습니다.' }
|
||||
}
|
||||
if (idSet.has(id)) {
|
||||
return { ok: false, message: '네비게이션 항목 id가 중복되었습니다.' }
|
||||
}
|
||||
idSet.add(id)
|
||||
}
|
||||
|
||||
const byId = new Map(items.map((i) => [String(i.id).trim(), i]))
|
||||
|
||||
for (const item of items) {
|
||||
const id = String(item.id).trim()
|
||||
const loc = item.location
|
||||
|
||||
if (loc === 'footer') {
|
||||
const p = item.parentId
|
||||
if (p != null && String(p).trim() !== '') {
|
||||
return { ok: false, message: '하단 네비게이션에는 하위 항목을 둘 수 없습니다.' }
|
||||
}
|
||||
}
|
||||
|
||||
const p = item.parentId
|
||||
if (p != null && String(p).trim() !== '') {
|
||||
const pid = String(p).trim()
|
||||
if (!byId.has(pid)) {
|
||||
return { ok: false, message: '상위 메뉴 참조가 잘못되었습니다.' }
|
||||
}
|
||||
const parent = byId.get(pid)
|
||||
if (parent.location !== loc) {
|
||||
return { ok: false, message: '상위 메뉴는 같은 위치(상단/하단)에 있어야 합니다.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxDepth = 12
|
||||
for (const item of items) {
|
||||
const path = new Set()
|
||||
let cur = item
|
||||
let depth = 0
|
||||
while (cur) {
|
||||
const cid = String(cur.id).trim()
|
||||
if (path.has(cid)) {
|
||||
return { ok: false, message: '메뉴 상위 참조에 순환이 있습니다.' }
|
||||
}
|
||||
path.add(cid)
|
||||
const p = cur.parentId
|
||||
if (p == null || String(p).trim() === '') {
|
||||
break
|
||||
}
|
||||
const pid = String(p).trim()
|
||||
cur = byId.get(pid)
|
||||
depth += 1
|
||||
if (depth > maxDepth) {
|
||||
return { ok: false, message: '메뉴 계층이 너무 깊습니다.' }
|
||||
}
|
||||
if (!cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 부모가 항상 자식보다 먼저 오도록 삽입 순서를 정한다.
|
||||
* @param {Array<Object>} items - 네비게이션 항목
|
||||
* @returns {Array<Object>} 정렬된 동일 배열의 얕은 복사
|
||||
*/
|
||||
export const orderNavigationItemsForInsert = (items) => {
|
||||
const byId = new Map(items.map((i) => [String(i.id).trim(), i]))
|
||||
const result = []
|
||||
const placed = new Set()
|
||||
|
||||
/**
|
||||
* @param {Object} item - 항목
|
||||
* @param {Set<string>} stack - 순환 방지
|
||||
* @returns {void}
|
||||
*/
|
||||
const place = (item, stack) => {
|
||||
const id = String(item.id).trim()
|
||||
if (placed.has(id)) {
|
||||
return
|
||||
}
|
||||
const p = item.parentId
|
||||
if (p != null && String(p).trim() !== '') {
|
||||
const pid = String(p).trim()
|
||||
if (byId.has(pid)) {
|
||||
if (stack.has(pid)) {
|
||||
return
|
||||
}
|
||||
stack.add(pid)
|
||||
place(byId.get(pid), stack)
|
||||
stack.delete(pid)
|
||||
}
|
||||
}
|
||||
if (!placed.has(id)) {
|
||||
result.push(item)
|
||||
placed.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
place(item, new Set())
|
||||
}
|
||||
|
||||
if (result.length !== items.length) {
|
||||
for (const item of items) {
|
||||
const id = String(item.id).trim()
|
||||
if (!placed.has(id)) {
|
||||
result.push(item)
|
||||
placed.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* location 기준 DFS로 sort_order를 10단위로 다시 부여한다.
|
||||
* @param {Array<Object>} items - 전체 항목(변경됨)
|
||||
* @param {'primary'|'footer'} location - 위치
|
||||
* @returns {void}
|
||||
*/
|
||||
export const renumberSortOrderByTree = (items, location) => {
|
||||
const tree = buildNavigationEditorTree(items, location)
|
||||
let n = 0
|
||||
|
||||
/**
|
||||
* @param {Array<{ item: Object, children: any[] }>} nodes - 노드
|
||||
* @returns {void}
|
||||
*/
|
||||
const walk = (nodes) => {
|
||||
for (const { item, children } of nodes) {
|
||||
n += 1
|
||||
item.sortOrder = n * 10
|
||||
if (children.length) {
|
||||
walk(children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree)
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 API용 primary 트리(순환 참조 없음).
|
||||
* @param {Array<Object>} flatPrimary - location primary인 항목만
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
export const buildPublicPrimaryTree = (flatPrimary) => {
|
||||
const list = flatPrimary.map((row) => ({
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
url: row.url,
|
||||
isFolder: Boolean(row.isFolder),
|
||||
isVisible: row.isVisible !== false,
|
||||
sortOrder: row.sortOrder || 0,
|
||||
parentId: row.parentId || null
|
||||
}))
|
||||
|
||||
const byId = new Map(list.map((i) => [String(i.id), { ...i, children: [] }]))
|
||||
const roots = []
|
||||
|
||||
for (const row of list) {
|
||||
const id = String(row.id)
|
||||
const node = byId.get(id)
|
||||
const p = row.parentId
|
||||
if (p && byId.has(String(p))) {
|
||||
byId.get(String(p)).children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<any>} nodes - 노드
|
||||
* @returns {void}
|
||||
*/
|
||||
const sortNodes = (nodes) => {
|
||||
nodes.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
for (const node of nodes) {
|
||||
if (node.children.length) {
|
||||
sortNodes(node.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sortNodes(roots)
|
||||
|
||||
/**
|
||||
* @param {any} node - 노드
|
||||
* @returns {Object}
|
||||
*/
|
||||
const strip = (node) => {
|
||||
const base = {
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
url: node.url,
|
||||
isFolder: node.isFolder,
|
||||
isVisible: node.isVisible
|
||||
}
|
||||
if (node.children.length) {
|
||||
return {
|
||||
...base,
|
||||
children: node.children.map(strip)
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
return roots.map(strip)
|
||||
}
|
||||
Reference in New Issue
Block a user