메뉴 관리: 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' '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 - 이벤트 * @param {DragEvent} event - 이벤트
@@ -45,6 +58,10 @@ const emit = defineEmits([
* @returns {void} * @returns {void}
*/ */
const onDragStart = (event, itemId) => { const onDragStart = (event, itemId) => {
if (shouldBlockRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) { if (!event.dataTransfer) {
return return
} }
@@ -81,80 +98,117 @@ const onDrop = (event, itemId) => {
const onDragEnd = () => { const onDragEnd = () => {
emit('drag-end') 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> </script>
<template> <template>
<ul class="admin-nav-primary-branch space-y-2" :class="depth ? 'mt-1 border-l border-line pl-3' : ''"> <div class="admin-nav-primary-branch" :class="depth ? 'mt-2 border-l border-line pl-3' : ''">
<li <div class="admin-nav-primary-branch__shell overflow-hidden rounded border border-line">
v-for="wrap in wraps" <table class="admin-nav-primary-branch__table w-full border-collapse text-left text-sm">
:key="wrap.item.id" <thead v-if="depth === 0" class="admin-nav-primary-branch__head bg-[#f5f5f2] text-xs uppercase text-muted">
class="admin-nav-primary-branch__row rounded border border-transparent bg-white px-2 py-2 transition-colors" <tr>
:class="dragOverId === wrap.item.id ? 'border-ink/20 bg-[#f5f5f2]' : draggingId === wrap.item.id ? 'opacity-60' : ''" <th class="admin-nav-primary-branch__cell px-4 py-3">
> #
<div class="admin-nav-primary-branch__controls flex flex-wrap items-center gap-2" :style="{ paddingLeft: `${depth * 4}px` }"> </th>
<span <th class="admin-nav-primary-branch__cell px-4 py-3">
class="admin-nav-primary-branch__handle cursor-grab select-none text-muted active:cursor-grabbing" 라벨
draggable="true" </th>
title="드래그하여 순서 변경" <th class="admin-nav-primary-branch__cell px-4 py-3">
@dragstart="onDragStart($event, wrap.item.id)" URL
@dragover="onDragOver($event, wrap.item.id)" </th>
@drop="onDrop($event, wrap.item.id)" <th class="admin-nav-primary-branch__cell px-4 py-3">
@dragend="onDragEnd" 관리
> </th>
:: </tr>
</span> </thead>
<label class="admin-nav-primary-branch__visible flex items-center gap-1 text-xs text-muted"> <tbody class="admin-nav-primary-branch__body divide-y divide-line bg-white">
<input v-model="wrap.item.isVisible" class="h-4 w-4" type="checkbox"> <template v-for="(wrap, index) in wraps" :key="wrap.item.id">
표시 <tr
</label> class="admin-nav-primary-branch__row cursor-move"
<label class="admin-nav-primary-branch__folder flex items-center gap-1 text-xs text-muted"> :class="rowStateClass(wrap.item.id)"
<input v-model="wrap.item.isFolder" class="h-4 w-4" type="checkbox"> draggable="true"
폴더 @dragstart="onDragStart($event, wrap.item.id)"
</label> @dragover="onDragOver($event, wrap.item.id)"
<input @drop="onDrop($event, wrap.item.id)"
v-model="wrap.item.label" @dragend="onDragEnd"
class="admin-nav-primary-branch__label min-w-[120px] flex-1 rounded border border-line px-2 py-1.5 text-sm" >
type="text" <td class="admin-nav-primary-branch__cell px-4 py-3 align-middle text-muted">
placeholder="라벨" {{ index + 1 }}
required </td>
> <td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
<input <input
v-model="wrap.item.url" v-model="wrap.item.label"
class="admin-nav-primary-branch__url min-w-[160px] flex-1 rounded border border-line px-2 py-1.5 text-sm font-mono" 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" type="text"
placeholder="URL (# 또는 /경로)" placeholder="라벨"
required required
> >
<button </td>
class="admin-nav-primary-branch__add-child rounded border border-line px-2 py-1 text-xs font-semibold" <td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
type="button" <input
@click="emit('add-child', wrap.item.id)" 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"
</button> placeholder="URL (# 또는 /경로)"
<button required
class="admin-nav-primary-branch__remove rounded border border-red-200 px-2 py-1 text-xs font-semibold text-red-700" >
type="button" </td>
@click="emit('remove', wrap.item.id)" <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
</button> class="admin-nav-primary-branch__add-child rounded border border-line px-3 py-1.5 text-xs font-semibold"
</div> type="button"
<AdminNavPrimaryBranch @click="emit('add-child', wrap.item.id)"
v-if="wrap.children.length" >
class="admin-nav-primary-branch__children mt-2" 하위
:wraps="wrap.children" </button>
:depth="depth + 1" <button
:parent-key="String(wrap.item.id)" class="admin-nav-primary-branch__remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
:dragging-id="draggingId" type="button"
:drag-over-id="dragOverId" @click="emit('remove', wrap.item.id)"
@drag-start="emit('drag-start', $event)" >
@drag-over="emit('drag-over', $event)" 삭제
@drag-end="emit('drag-end')" </button>
@drop="emit('drop', $event)" </div>
@add-child="emit('add-child', $event)" </td>
@remove="emit('remove', $event)" </tr>
/> <tr v-if="wrap.children.length" class="admin-nav-primary-branch__nest bg-white">
</li> <td class="p-0" colspan="4">
</ul> <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> </template>

View File

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

View File

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

View File

@@ -517,11 +517,12 @@ components/content/
### 메뉴/네비게이션 ### 메뉴/네비게이션
- 네비게이션은 `navigation_items` 테이블로 관리한다. - 네비게이션은 `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`만 내려간다. - `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, 폴더 전용 자리 표시 `#`를 허용한다. - 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`에 저장된다. - 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링하며, 하위가 있는 노드는 chevron으로 펼침·접기한다. 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다.
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다. - 관리자 메뉴 관리 화면에서 저장 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 ## v0.0.94
- 메뉴 관리: 상단/하단 탭 분리, 순서는 드래그(태그 관리와 유사). 상단은 `parent_id` 트리·하위 추가·폴더(`is_folder`)·동일 부모 내 순서 변경. - 메뉴 관리: 상단/하단 탭 분리, 순서는 드래그(태그 관리와 유사). 상단은 `parent_id` 트리·하위 추가·폴더(`is_folder`)·동일 부모 내 순서 변경.

View File

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

View File

@@ -16,7 +16,8 @@ const { data: navigationItems } = await useFetch('/admin/api/navigation', {
const items = ref(navigationItems.value.map((item) => ({ const items = ref(navigationItems.value.map((item) => ({
...item, ...item,
parentId: item.parentId ?? null, parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder) isFolder: Boolean(item.isFolder),
isVisible: true
}))) })))
const navDraggingId = ref('') const navDraggingId = ref('')
@@ -45,8 +46,6 @@ const serializeNavigationItems = (list) => JSON.stringify(
url: String(item.url || '').trim(), url: String(item.url || '').trim(),
location: item.location, location: item.location,
sortOrder: Number(item.sortOrder || 0), sortOrder: Number(item.sortOrder || 0),
isVisible: Boolean(item.isVisible),
isFolder: Boolean(item.isFolder),
parentId: item.parentId ? String(item.parentId).trim() : null parentId: item.parentId ? String(item.parentId).trim() : null
})) }))
) )
@@ -181,6 +180,19 @@ const onPrimaryDrop = ({ parentKey, targetId }) => {
onPrimaryDragEnd() 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 - 이벤트 * @param {DragEvent} event - 이벤트
@@ -188,6 +200,10 @@ const onPrimaryDrop = ({ parentKey, targetId }) => {
* @returns {void} * @returns {void}
*/ */
const onFooterDragStart = (event, id) => { const onFooterDragStart = (event, id) => {
if (shouldBlockFooterRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) { if (!event.dataTransfer) {
return return
} }
@@ -231,6 +247,21 @@ const onFooterDragEnd = () => {
footerDragOverId.value = '' 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} * @returns {void}
@@ -313,7 +344,7 @@ const saveNavigation = async () => {
url: item.url, url: item.url,
location: item.location, location: item.location,
sortOrder: Number(item.sortOrder || 0), sortOrder: Number(item.sortOrder || 0),
isVisible: Boolean(item.isVisible), isVisible: true,
parentId: item.parentId ?? null, parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder) isFolder: Boolean(item.isFolder)
})) }))
@@ -323,12 +354,14 @@ const saveNavigation = async () => {
items.value = savedItems.map((item) => ({ items.value = savedItems.map((item) => ({
...item, ...item,
parentId: item.parentId ?? null, parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder) isFolder: Boolean(item.isFolder),
isVisible: true
})) }))
navigationBaseline.value = serializeNavigationItems(items.value) navigationBaseline.value = serializeNavigationItems(items.value)
showToast('success', '네비게이션이 저장되었습니다.') showToast('success', '네비게이션이 저장되었습니다.')
} catch (error) { } catch (error) {
showToast('error', error?.data?.message || '네비게이션을 저장하지 못했습니다.') const msg = error?.data?.message || error?.message || '네비게이션을 저장하지 못했습니다.'
showToast('error', msg)
} finally { } finally {
saving.value = false saving.value = false
} }
@@ -346,7 +379,10 @@ const saveNavigation = async () => {
메뉴 관리 메뉴 관리
</h1> </h1>
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted"> <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> </p>
</div> </div>
<div class="admin-navigation__header-actions flex flex-wrap gap-2"> <div class="admin-navigation__header-actions flex flex-wrap gap-2">
@@ -425,51 +461,70 @@ const saveNavigation = async () => {
하단 메뉴가 없습니다. 하단 메뉴가 없습니다.
</div> </div>
<ul v-else class="admin-navigation__footer-list space-y-2 rounded border border-line bg-white p-4"> <div v-else class="admin-navigation__footer-table overflow-hidden rounded border border-line">
<li <table class="admin-navigation__footer-table-inner w-full border-collapse text-left text-sm">
v-for="item in footerItemsSorted" <thead class="admin-navigation__footer-head bg-[#f5f5f2] text-xs uppercase text-muted">
:key="item.id" <tr>
class="admin-navigation__footer-row flex flex-wrap items-center gap-2 rounded border border-transparent px-2 py-2 transition-colors" <th class="admin-navigation__footer-cell px-4 py-3">
:class="footerDragOverId === item.id ? 'border-ink/20 bg-[#f5f5f2]' : ''" #
> </th>
<span <th class="admin-navigation__footer-cell px-4 py-3">
class="admin-navigation__footer-handle cursor-grab select-none text-muted active:cursor-grabbing" 라벨
draggable="true" </th>
title="드래그하여 순서 변경" <th class="admin-navigation__footer-cell px-4 py-3">
@dragstart="onFooterDragStart($event, item.id)" URL
@dragover="onFooterDragOver($event, item.id)" </th>
@drop="onFooterDrop($event, item.id)" <th class="admin-navigation__footer-cell px-4 py-3">
@dragend="onFooterDragEnd" 관리
> </th>
:: </tr>
</span> </thead>
<label class="flex items-center gap-1 text-xs text-muted"> <tbody class="admin-navigation__footer-body divide-y divide-line bg-white">
<input v-model="item.isVisible" class="h-4 w-4" type="checkbox"> <tr
표시 v-for="(item, index) in footerItemsSorted"
</label> :key="item.id"
<input class="admin-navigation__footer-row cursor-move"
v-model="item.label" :class="footerRowClass(item.id)"
class="min-w-[100px] flex-1 rounded border border-line px-2 py-1.5 text-sm" draggable="true"
type="text" @dragstart="onFooterDragStart($event, item.id)"
placeholder="라벨" @dragover="onFooterDragOver($event, item.id)"
required @drop="onFooterDrop($event, item.id)"
> @dragend="onFooterDragEnd"
<input >
v-model="item.url" <td class="admin-navigation__footer-cell px-4 py-4 text-muted">
class="min-w-[140px] flex-1 rounded border border-line px-2 py-1.5 text-sm font-mono" {{ index + 1 }}
type="text" </td>
placeholder="URL" <td class="admin-navigation__footer-cell px-4 py-4">
required <input
> v-model="item.label"
<button class="w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
class="rounded border border-red-200 px-2 py-1 text-xs font-semibold text-red-700" type="text"
type="button" placeholder="라벨"
@click="removeItemCascade(item.id)" required
> >
삭제 </td>
</button> <td class="admin-navigation__footer-cell px-4 py-4">
</li> <input
</ul> 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>
<div <div

View File

@@ -354,7 +354,10 @@ onBeforeUnmount(() => {
v-for="(tag, index) in managedTags" v-for="(tag, index) in managedTags"
:key="tag.id" :key="tag.id"
class="admin-tags__row cursor-move" 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" draggable="true"
@dragstart="handleDragStart($event, tag.id)" @dragstart="handleDragStart($event, tag.id)"
@dragover="handleDragOver($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 { updateNavigationItems } from '../../../repositories/content-repository'
import { renumberSortOrderByTree, validateNavigationItems } from '../../../utils/navigation-tree' 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 * 관리자 네비게이션 일괄 저장 API
* @param {import('h3').H3Event} event - 요청 이벤트 * @param {import('h3').H3Event} event - 요청 이벤트
@@ -27,9 +47,9 @@ export default defineEventHandler(async (event) => {
url: row.url.trim(), url: row.url.trim(),
location: row.location, location: row.location,
sortOrder: row.sortOrder, sortOrder: row.sortOrder,
isVisible: row.isVisible, isVisible: true,
parentId: row.parentId ?? null, parentId: row.parentId ?? null,
isFolder: Boolean(row.isFolder) isFolder: false
})) }))
const checked = validateNavigationItems(items) const checked = validateNavigationItems(items)
@@ -42,6 +62,19 @@ export default defineEventHandler(async (event) => {
renumberSortOrderByTree(items, 'primary') renumberSortOrderByTree(items, 'primary')
renumberSortOrderByTree(items, 'footer') 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
}
}) })