메뉴 관리: parent_id 오류 안내·표시/폴더 제거·태그형 드래그 UX (v0.0.95)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 11:53:39 +09:00
parent bcff96aa4c
commit 8b8a80034d
9 changed files with 293 additions and 135 deletions

View File

@@ -38,6 +38,19 @@ const emit = defineEmits([
'remove'
])
/**
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
* @param {DragEvent} event - 이벤트
* @returns {boolean} true면 드래그를 막는다
*/
const shouldBlockRowDrag = (event) => {
const el = event.target
if (!el || typeof el.closest !== 'function') {
return false
}
return Boolean(el.closest('input, button, textarea, select, a'))
}
/**
* 드래그 시작
* @param {DragEvent} event - 이벤트
@@ -45,6 +58,10 @@ const emit = defineEmits([
* @returns {void}
*/
const onDragStart = (event, itemId) => {
if (shouldBlockRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) {
return
}
@@ -81,80 +98,117 @@ const onDrop = (event, itemId) => {
const onDragEnd = () => {
emit('drag-end')
}
/**
* 행 하이라이트 클래스(태그 관리 메인 태그 테이블과 동일 톤)
* @param {string} itemId - 항목 id
* @returns {string}
*/
const rowStateClass = (itemId) => {
const id = String(itemId)
if (props.dragOverId === id) {
return 'bg-[#f9f9f7]'
}
if (props.draggingId === id) {
return 'opacity-50'
}
return ''
}
</script>
<template>
<ul class="admin-nav-primary-branch space-y-2" :class="depth ? 'mt-1 border-l border-line pl-3' : ''">
<li
v-for="wrap in wraps"
:key="wrap.item.id"
class="admin-nav-primary-branch__row rounded border border-transparent bg-white px-2 py-2 transition-colors"
:class="dragOverId === wrap.item.id ? 'border-ink/20 bg-[#f5f5f2]' : draggingId === wrap.item.id ? 'opacity-60' : ''"
>
<div class="admin-nav-primary-branch__controls flex flex-wrap items-center gap-2" :style="{ paddingLeft: `${depth * 4}px` }">
<span
class="admin-nav-primary-branch__handle cursor-grab select-none text-muted active:cursor-grabbing"
draggable="true"
title="드래그하여 순서 변경"
@dragstart="onDragStart($event, wrap.item.id)"
@dragover="onDragOver($event, wrap.item.id)"
@drop="onDrop($event, wrap.item.id)"
@dragend="onDragEnd"
>
::
</span>
<label class="admin-nav-primary-branch__visible flex items-center gap-1 text-xs text-muted">
<input v-model="wrap.item.isVisible" class="h-4 w-4" type="checkbox">
표시
</label>
<label class="admin-nav-primary-branch__folder flex items-center gap-1 text-xs text-muted">
<input v-model="wrap.item.isFolder" class="h-4 w-4" type="checkbox">
폴더
</label>
<input
v-model="wrap.item.label"
class="admin-nav-primary-branch__label min-w-[120px] flex-1 rounded border border-line px-2 py-1.5 text-sm"
type="text"
placeholder="라벨"
required
>
<input
v-model="wrap.item.url"
class="admin-nav-primary-branch__url min-w-[160px] flex-1 rounded border border-line px-2 py-1.5 text-sm font-mono"
type="text"
placeholder="URL (# 또는 /경로)"
required
>
<button
class="admin-nav-primary-branch__add-child rounded border border-line px-2 py-1 text-xs font-semibold"
type="button"
@click="emit('add-child', wrap.item.id)"
>
하위
</button>
<button
class="admin-nav-primary-branch__remove rounded border border-red-200 px-2 py-1 text-xs font-semibold text-red-700"
type="button"
@click="emit('remove', wrap.item.id)"
>
삭제
</button>
</div>
<AdminNavPrimaryBranch
v-if="wrap.children.length"
class="admin-nav-primary-branch__children mt-2"
:wraps="wrap.children"
:depth="depth + 1"
:parent-key="String(wrap.item.id)"
:dragging-id="draggingId"
:drag-over-id="dragOverId"
@drag-start="emit('drag-start', $event)"
@drag-over="emit('drag-over', $event)"
@drag-end="emit('drag-end')"
@drop="emit('drop', $event)"
@add-child="emit('add-child', $event)"
@remove="emit('remove', $event)"
/>
</li>
</ul>
<div class="admin-nav-primary-branch" :class="depth ? 'mt-2 border-l border-line pl-3' : ''">
<div class="admin-nav-primary-branch__shell overflow-hidden rounded border border-line">
<table class="admin-nav-primary-branch__table w-full border-collapse text-left text-sm">
<thead v-if="depth === 0" class="admin-nav-primary-branch__head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-nav-primary-branch__cell px-4 py-3">
#
</th>
<th class="admin-nav-primary-branch__cell px-4 py-3">
라벨
</th>
<th class="admin-nav-primary-branch__cell px-4 py-3">
URL
</th>
<th class="admin-nav-primary-branch__cell px-4 py-3">
관리
</th>
</tr>
</thead>
<tbody class="admin-nav-primary-branch__body divide-y divide-line bg-white">
<template v-for="(wrap, index) in wraps" :key="wrap.item.id">
<tr
class="admin-nav-primary-branch__row cursor-move"
:class="rowStateClass(wrap.item.id)"
draggable="true"
@dragstart="onDragStart($event, wrap.item.id)"
@dragover="onDragOver($event, wrap.item.id)"
@drop="onDrop($event, wrap.item.id)"
@dragend="onDragEnd"
>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle text-muted">
{{ index + 1 }}
</td>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
<input
v-model="wrap.item.label"
class="admin-nav-primary-branch__label w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="라벨"
required
>
</td>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
<input
v-model="wrap.item.url"
class="admin-nav-primary-branch__url w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL (# 또는 /경로)"
required
>
</td>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
<div class="admin-nav-primary-branch__actions flex flex-wrap gap-2">
<button
class="admin-nav-primary-branch__add-child rounded border border-line px-3 py-1.5 text-xs font-semibold"
type="button"
@click="emit('add-child', wrap.item.id)"
>
하위
</button>
<button
class="admin-nav-primary-branch__remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
type="button"
@click="emit('remove', wrap.item.id)"
>
삭제
</button>
</div>
</td>
</tr>
<tr v-if="wrap.children.length" class="admin-nav-primary-branch__nest bg-white">
<td class="p-0" colspan="4">
<div class="admin-nav-primary-branch__nest-inner border-t border-line bg-[#fafaf8] px-2 py-3">
<AdminNavPrimaryBranch
:wraps="wrap.children"
:depth="depth + 1"
:parent-key="String(wrap.item.id)"
:dragging-id="draggingId"
:drag-over-id="dragOverId"
@drag-start="emit('drag-start', $event)"
@drag-over="emit('drag-over', $event)"
@drag-end="emit('drag-end')"
@drop="emit('drop', $event)"
@add-child="emit('add-child', $event)"
@remove="emit('remove', $event)"
/>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-12 v0.0.95
### 메뉴 관리 단순화와 드래그 UX
`is_visible` 체크는 “숨기기” 용도였으나 목록에 두면 모두 표시해야 한다는 운영 기대와 맞지 않아 제거하고 항상 공개로 저장한다. `is_folder`는 자식 존재로만 서버가 채운다. 드래그는 태그 관리와 같은 행 단위 시각 피드백으로 맞췄다.
## 2026-05-12 v0.0.94
### 메뉴 관리 UX와 상단 네비 트리

View File

@@ -52,7 +52,7 @@
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력(한글 유지), 로컬 자동 저장, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리 편집(드래그·하위 추가·폴더 체크, 재귀) |
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
## 콘텐츠 컴포넌트
@@ -91,7 +91,7 @@
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 상단 트리+드래그(동일 부모 내), 하단 평면 드래그, `useAdminToast` |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast`, 마이그레이션 안내 |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |

View File

@@ -517,11 +517,12 @@ components/content/
### 메뉴/네비게이션
- 네비게이션은 `navigation_items` 테이블로 관리한다.
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, 상단 그룹 표시용), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`. `(location, label, url)` 유니크 제약은 제거되었다.
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
- `GET /api/navigation` 응답: `primary`**트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. `footer`**평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다.
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id``primary`에서만 허용되며 `footer` 항목은 항상 루트다.
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id``primary`에서만 허용되며 `footer` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다(위치 셀렉트 없음). 상단은 `AdminNavPrimaryBranch`로 트리 편집·같은 부모 내 드래그 순서 변경·하위 추가·폴더 체크를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev``017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링하며, 하위가 있는 노드는 chevron으로 펼침·접기한다. 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다.
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v0.0.95
- 메뉴 관리: `표시`·`폴더` 체크 제거(항목은 항상 공개, `is_folder`는 저장 시 자식 유무로 서버 설정). 테이블+행 드래그 UX를 태그 메인 태그와 동일 톤(`bg-[#f9f9f7]`·`opacity-50`, 입력 위에서는 드래그 시작 안 함).
- 태그 관리 메인 태그: 드래그 중인 행에 `opacity-50` 적용(메뉴 관리와 동일한 이탈 피드백).
- `PUT /admin/api/navigation`: `parent_id`/`is_folder` 컬럼 부재 시 한국어 안내(503) 및 화면에 마이그레이션 안내 블록.
## v0.0.94
- 메뉴 관리: 상단/하단 탭 분리, 순서는 드래그(태그 관리와 유사). 상단은 `parent_id` 트리·하위 추가·폴더(`is_folder`)·동일 부모 내 순서 변경.

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.94",
"version": "0.0.95",
"private": true,
"type": "module",
"imports": {

View File

@@ -16,7 +16,8 @@ const { data: navigationItems } = await useFetch('/admin/api/navigation', {
const items = ref(navigationItems.value.map((item) => ({
...item,
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder)
isFolder: Boolean(item.isFolder),
isVisible: true
})))
const navDraggingId = ref('')
@@ -45,8 +46,6 @@ const serializeNavigationItems = (list) => JSON.stringify(
url: String(item.url || '').trim(),
location: item.location,
sortOrder: Number(item.sortOrder || 0),
isVisible: Boolean(item.isVisible),
isFolder: Boolean(item.isFolder),
parentId: item.parentId ? String(item.parentId).trim() : null
}))
)
@@ -181,6 +180,19 @@ const onPrimaryDrop = ({ parentKey, targetId }) => {
onPrimaryDragEnd()
}
/**
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
* @param {DragEvent} event - 이벤트
* @returns {boolean}
*/
const shouldBlockFooterRowDrag = (event) => {
const el = event.target
if (!el || typeof el.closest !== 'function') {
return false
}
return Boolean(el.closest('input, button, textarea, select, a'))
}
/**
* 하단 행 드래그 시작
* @param {DragEvent} event - 이벤트
@@ -188,6 +200,10 @@ const onPrimaryDrop = ({ parentKey, targetId }) => {
* @returns {void}
*/
const onFooterDragStart = (event, id) => {
if (shouldBlockFooterRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) {
return
}
@@ -231,6 +247,21 @@ const onFooterDragEnd = () => {
footerDragOverId.value = ''
}
/**
* 하단 행 하이라이트(태그 관리와 동일)
* @param {string} id - 항목 id
* @returns {string}
*/
const footerRowClass = (id) => {
if (footerDragOverId.value === id) {
return 'bg-[#f9f9f7]'
}
if (footerDraggingId.value === id) {
return 'opacity-50'
}
return ''
}
/**
* 상단 루트 항목 추가
* @returns {void}
@@ -313,7 +344,7 @@ const saveNavigation = async () => {
url: item.url,
location: item.location,
sortOrder: Number(item.sortOrder || 0),
isVisible: Boolean(item.isVisible),
isVisible: true,
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder)
}))
@@ -323,12 +354,14 @@ const saveNavigation = async () => {
items.value = savedItems.map((item) => ({
...item,
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder)
isFolder: Boolean(item.isFolder),
isVisible: true
}))
navigationBaseline.value = serializeNavigationItems(items.value)
showToast('success', '네비게이션이 저장되었습니다.')
} catch (error) {
showToast('error', error?.data?.message || '네비게이션을 저장하지 못했습니다.')
const msg = error?.data?.message || error?.message || '네비게이션을 저장하지 못했습니다.'
showToast('error', msg)
} finally {
saving.value = false
}
@@ -346,7 +379,10 @@ const saveNavigation = async () => {
메뉴 관리
</h1>
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted">
상단·하단을 탭으로 누어 편집합니다. 상단은 드래그(::) 같은 단계끼리 순서를 바꿀 있고, 폴더·하위 메뉴를 습니다. 단은 목록만 지원합니다.
상단·하단을 탭으로 눕니다. 순서는 <span class="font-semibold text-ink">태그 관리의 메인 태그</span> 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 습니다. 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다.
</p>
<p class="admin-navigation__migrate-hint mt-2 max-w-xl rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
저장 <code class="rounded bg-white/80 px-1">parent_id</code> 오류가 나면 DB에 마이그레이션이 아직 없는 것입니다. 로컬에서 <code class="rounded bg-white/80 px-1">npm run db:migrate:dev</code> <code class="rounded bg-white/80 px-1">017_navigation_hierarchy.sql</code> 적용하세요.
</p>
</div>
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
@@ -425,51 +461,70 @@ const saveNavigation = async () => {
하단 메뉴가 없습니다.
</div>
<ul v-else class="admin-navigation__footer-list space-y-2 rounded border border-line bg-white p-4">
<li
v-for="item in footerItemsSorted"
:key="item.id"
class="admin-navigation__footer-row flex flex-wrap items-center gap-2 rounded border border-transparent px-2 py-2 transition-colors"
:class="footerDragOverId === item.id ? 'border-ink/20 bg-[#f5f5f2]' : ''"
>
<span
class="admin-navigation__footer-handle cursor-grab select-none text-muted active:cursor-grabbing"
draggable="true"
title="드래그하여 순서 변경"
@dragstart="onFooterDragStart($event, item.id)"
@dragover="onFooterDragOver($event, item.id)"
@drop="onFooterDrop($event, item.id)"
@dragend="onFooterDragEnd"
>
::
</span>
<label class="flex items-center gap-1 text-xs text-muted">
<input v-model="item.isVisible" class="h-4 w-4" type="checkbox">
표시
</label>
<input
v-model="item.label"
class="min-w-[100px] flex-1 rounded border border-line px-2 py-1.5 text-sm"
type="text"
placeholder="라벨"
required
>
<input
v-model="item.url"
class="min-w-[140px] flex-1 rounded border border-line px-2 py-1.5 text-sm font-mono"
type="text"
placeholder="URL"
required
>
<button
class="rounded border border-red-200 px-2 py-1 text-xs font-semibold text-red-700"
type="button"
@click="removeItemCascade(item.id)"
>
삭제
</button>
</li>
</ul>
<div v-else class="admin-navigation__footer-table overflow-hidden rounded border border-line">
<table class="admin-navigation__footer-table-inner w-full border-collapse text-left text-sm">
<thead class="admin-navigation__footer-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-navigation__footer-cell px-4 py-3">
#
</th>
<th class="admin-navigation__footer-cell px-4 py-3">
라벨
</th>
<th class="admin-navigation__footer-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__footer-cell px-4 py-3">
관리
</th>
</tr>
</thead>
<tbody class="admin-navigation__footer-body divide-y divide-line bg-white">
<tr
v-for="(item, index) in footerItemsSorted"
:key="item.id"
class="admin-navigation__footer-row cursor-move"
:class="footerRowClass(item.id)"
draggable="true"
@dragstart="onFooterDragStart($event, item.id)"
@dragover="onFooterDragOver($event, item.id)"
@drop="onFooterDrop($event, item.id)"
@dragend="onFooterDragEnd"
>
<td class="admin-navigation__footer-cell px-4 py-4 text-muted">
{{ index + 1 }}
</td>
<td class="admin-navigation__footer-cell px-4 py-4">
<input
v-model="item.label"
class="w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="라벨"
required
>
</td>
<td class="admin-navigation__footer-cell px-4 py-4">
<input
v-model="item.url"
class="w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL"
required
>
</td>
<td class="admin-navigation__footer-cell px-4 py-4">
<button
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
type="button"
@click="removeItemCascade(item.id)"
>
삭제
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div

View File

@@ -354,7 +354,10 @@ onBeforeUnmount(() => {
v-for="(tag, index) in managedTags"
:key="tag.id"
class="admin-tags__row cursor-move"
:class="dragOverTagId === tag.id ? 'bg-[#f9f9f7]' : ''"
:class="[
dragOverTagId === tag.id ? 'bg-[#f9f9f7]' : '',
draggingTagId === tag.id ? 'opacity-50' : ''
]"
draggable="true"
@dragstart="handleDragStart($event, tag.id)"
@dragover="handleDragOver($event, tag.id)"

View File

@@ -4,6 +4,26 @@ import { parseAdminNavigationInput } from '../../../utils/admin-navigation-input
import { updateNavigationItems } from '../../../repositories/content-repository'
import { renumberSortOrderByTree, validateNavigationItems } from '../../../utils/navigation-tree'
/**
* 저장 직전에 항목별 표시·폴더 플래그를 정리한다(관리자 UI는 항상 노출, 폴더는 자식 유무로만).
* @param {Array<Object>} items - 항목 목록(변경됨)
* @returns {void}
*/
const applyNavigationDerivedFlags = (items) => {
for (const item of items) {
item.isVisible = true
const id = String(item.id).trim()
item.isFolder =
item.location === 'primary' &&
items.some(
(o) =>
o.location === 'primary' &&
o.parentId != null &&
String(o.parentId).trim() === id
)
}
}
/**
* 관리자 네비게이션 일괄 저장 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -27,9 +47,9 @@ export default defineEventHandler(async (event) => {
url: row.url.trim(),
location: row.location,
sortOrder: row.sortOrder,
isVisible: row.isVisible,
isVisible: true,
parentId: row.parentId ?? null,
isFolder: Boolean(row.isFolder)
isFolder: false
}))
const checked = validateNavigationItems(items)
@@ -42,6 +62,19 @@ export default defineEventHandler(async (event) => {
renumberSortOrderByTree(items, 'primary')
renumberSortOrderByTree(items, 'footer')
applyNavigationDerivedFlags(items)
return updateNavigationItems(items)
try {
return await updateNavigationItems(items)
} catch (err) {
const msg = err?.message != null ? String(err.message) : String(err)
const code = err?.code != null ? String(err.code) : ''
if (msg.includes('parent_id') || msg.includes('is_folder') || code === '42703') {
throw createError({
statusCode: 503,
message: 'DB에 navigation_items 확장 컬럼이 없습니다. 프로젝트 루트에서 npm run db:migrate:dev 로 017_navigation_hierarchy.sql을 적용한 뒤 다시 저장하세요.'
})
}
throw err
}
})