상단 네비는 평면 테이블·드래그로 편집하고 한 단계 하위만 허용한다. 추천 사이트 탭·location recommended·공개 API와 우측 사이드 카드·파비콘 URL 유틸을 추가한다. 문서·배포 마이그레이션 안내·관리자 레이아웃·설정 화면 등 누적 변경을 반영한다.
285 lines
7.5 KiB
JavaScript
285 lines
7.5 KiB
JavaScript
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: '하단 네비게이션에는 하위 항목을 둘 수 없습니다.' }
|
|
}
|
|
}
|
|
|
|
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()
|
|
if (!byId.has(pid)) {
|
|
return { ok: false, message: '상위 메뉴 참조가 잘못되었습니다.' }
|
|
}
|
|
const parent = byId.get(pid)
|
|
if (parent.location !== loc) {
|
|
return { ok: false, message: '상위 메뉴는 같은 위치(상단/하단/추천)에 있어야 합니다.' }
|
|
}
|
|
if (loc === 'primary') {
|
|
const gp = parent.parentId
|
|
if (gp != null && String(gp).trim() !== '') {
|
|
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'|'recommended'} 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 트리(순환 참조 없음).
|
|
* 동일 id가 평면 목록에 중복되거나(시드·이전 마이그레이션 등), 한 행은 루트·다른 행은 자식으로만 잡히면
|
|
* 기존 한 루프 방식으로는 같은 노드가 roots와 children에 동시에 들어갈 수 있어, 자식으로 연결된 id는 루트에서 제외한다.
|
|
* @param {Array<Object>} flatPrimary - location primary인 항목만
|
|
* @returns {Array<Object>}
|
|
*/
|
|
export const buildPublicPrimaryTree = (flatPrimary) => {
|
|
const sorted = [...(flatPrimary || [])].sort((a, b) => {
|
|
const sa = a.sortOrder || 0
|
|
const sb = b.sortOrder || 0
|
|
if (sa !== sb) {
|
|
return sa - sb
|
|
}
|
|
const la = String(a.label || '')
|
|
const lb = String(b.label || '')
|
|
if (la !== lb) {
|
|
return la.localeCompare(lb)
|
|
}
|
|
return String(a.id).localeCompare(String(b.id))
|
|
})
|
|
|
|
const idSeen = new Set()
|
|
const deduped = []
|
|
for (const row of sorted) {
|
|
const id = String(row.id)
|
|
if (idSeen.has(id)) {
|
|
continue
|
|
}
|
|
idSeen.add(id)
|
|
deduped.push(row)
|
|
}
|
|
|
|
const list = deduped.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 attachedAsChild = new Set()
|
|
|
|
for (const row of list) {
|
|
const id = String(row.id)
|
|
const node = byId.get(id)
|
|
const p = row.parentId
|
|
const pid = p != null && String(p).trim() !== '' ? String(p).trim() : ''
|
|
if (pid && pid !== id && byId.has(pid)) {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
const roots = []
|
|
for (const row of list) {
|
|
const id = String(row.id)
|
|
if (!attachedAsChild.has(id)) {
|
|
roots.push(byId.get(id))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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)
|
|
}
|