메뉴 관리 개편, 추천 사이트·1뎁스 네비, 우측 Recommended 연동(v1.1.13)

상단 네비는 평면 테이블·드래그로 편집하고 한 단계 하위만 허용한다.
추천 사이트 탭·location recommended·공개 API와 우측 사이드 카드·파비콘 URL 유틸을 추가한다.
문서·배포 마이그레이션 안내·관리자 레이아웃·설정 화면 등 누적 변경을 반영한다.
This commit is contained in:
2026-05-15 14:20:27 +09:00
parent 2768975752
commit ca1e17890b
24 changed files with 1509 additions and 499 deletions

View File

@@ -2,6 +2,6 @@ import { getPublicNavigation } from '../repositories/content-repository'
/**
* 공개 네비게이션 API
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} `primary`는 트리(`children` 선택), `footer`는 평면
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>, recommended: Array<Object>}>} `primary`는 트리(`children` 선택), `footer`·`recommended`는 평면
*/
export default defineEventHandler(() => getPublicNavigation())

View File

@@ -894,7 +894,7 @@ export const listNavigationItems = async ({ visibleOnly = false } = {}) => {
/**
* 공개 네비게이션 조회
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 공개 네비게이션
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>, recommended: Array<Object>}>} 위치별 공개 네비게이션
*/
export const getPublicNavigation = async () => {
const flat = await listNavigationItems({ visibleOnly: true })
@@ -902,6 +902,9 @@ export const getPublicNavigation = async () => {
const footerFlat = flat
.filter((item) => item.location === 'footer' && !item.parentId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
const recommendedFlat = flat
.filter((item) => item.location === 'recommended' && !item.parentId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
return {
primary: buildPublicPrimaryTree(primaryFlat),
@@ -910,6 +913,12 @@ export const getPublicNavigation = async () => {
label: item.label,
url: item.url,
isVisible: item.isVisible
})),
recommended: recommendedFlat.map((item) => ({
id: item.id,
label: item.label,
url: item.url,
isVisible: item.isVisible
}))
}
}

View File

@@ -62,6 +62,7 @@ export default defineEventHandler(async (event) => {
renumberSortOrderByTree(items, 'primary')
renumberSortOrderByTree(items, 'footer')
renumberSortOrderByTree(items, 'recommended')
applyNavigationDerivedFlags(items)
try {

View File

@@ -4,17 +4,17 @@ export const adminNavigationItemInputSchema = z.object({
id: z.string().uuid(),
label: z.string().trim().min(1),
url: z.string().trim().min(1).regex(/^(\/|https?:\/\/|#)/),
location: z.enum(['primary', 'footer']),
location: z.enum(['primary', 'footer', 'recommended']),
sortOrder: z.coerce.number().int().min(0).default(0),
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) {
if ((data.location === 'footer' || data.location === 'recommended') && data.parentId) {
ctx.addIssue({
code: 'custom',
path: ['parentId'],
message: '하단 메뉴는 하위 항목을 가질 수 없습니다.'
message: '해당 위치 메뉴는 하위 항목을 가질 수 없습니다.'
})
}
})

View File

@@ -35,6 +35,13 @@ export const validateNavigationItems = (items) => {
}
}
if (loc === 'recommended') {
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()
@@ -43,7 +50,13 @@ export const validateNavigationItems = (items) => {
}
const parent = byId.get(pid)
if (parent.location !== loc) {
return { ok: false, message: '상위 메뉴는 같은 위치(상단/하단)에 있어야 합니다.' }
return { ok: false, message: '상위 메뉴는 같은 위치(상단/하단/추천)에 있어야 합니다.' }
}
if (loc === 'primary') {
const gp = parent.parentId
if (gp != null && String(gp).trim() !== '') {
return { ok: false, message: '상단 메뉴는 하위 한 단계까지만 허용됩니다.' }
}
}
}
}
@@ -136,7 +149,7 @@ export const orderNavigationItemsForInsert = (items) => {
/**
* location 기준 DFS로 sort_order를 10단위로 다시 부여한다.
* @param {Array<Object>} items - 전체 항목(변경됨)
* @param {'primary'|'footer'} location - 위치
* @param {'primary'|'footer'|'recommended'} location - 위치
* @returns {void}
*/
export const renumberSortOrderByTree = (items, location) => {
@@ -212,8 +225,14 @@ export const buildPublicPrimaryTree = (flatPrimary) => {
const p = row.parentId
const pid = p != null && String(p).trim() !== '' ? String(p).trim() : ''
if (pid && pid !== id && byId.has(pid)) {
byId.get(pid).children.push(node)
attachedAsChild.add(id)
const parentRow = list.find((r) => String(r.id) === pid)
const parentIsRoot =
parentRow &&
(parentRow.parentId == null || String(parentRow.parentId).trim() === '')
if (parentIsRoot) {
byId.get(pid).children.push(node)
attachedAsChild.add(id)
}
}
}