diff --git a/docs/history.md b/docs/history.md
index d0c6e35..8cfaa7b 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -1,5 +1,11 @@
# 의사결정 이력
+## 2026-05-12 v0.0.95
+
+### 메뉴 관리 단순화와 드래그 UX
+
+`is_visible` 체크는 “숨기기” 용도였으나 목록에 두면 모두 표시해야 한다는 운영 기대와 맞지 않아 제거하고 항상 공개로 저장한다. `is_folder`는 자식 존재로만 서버가 채운다. 드래그는 태그 관리와 같은 행 단위 시각 피드백으로 맞췄다.
+
## 2026-05-12 v0.0.94
### 메뉴 관리 UX와 상단 네비 트리
diff --git a/docs/map.md b/docs/map.md
index a8496e9..0e56a97 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -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 | 태그 수정 |
diff --git a/docs/spec.md b/docs/spec.md
index a95c201..b7153d9 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -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로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
diff --git a/docs/update.md b/docs/update.md
index 1445c9f..afcb86d 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -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`)·동일 부모 내 순서 변경.
diff --git a/package.json b/package.json
index 2bc4d9a..c4fb252 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "0.0.94",
+ "version": "0.0.95",
"private": true,
"type": "module",
"imports": {
diff --git a/pages/admin/navigation/index.vue b/pages/admin/navigation/index.vue
index 8dcc4c6..7820d24 100644
--- a/pages/admin/navigation/index.vue
+++ b/pages/admin/navigation/index.vue
@@ -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 () => {
메뉴 관리
- 상단·하단을 탭으로 나누어 편집합니다. 상단은 드래그(::)로 같은 단계끼리 순서를 바꿀 수 있고, 폴더·하위 메뉴를 둘 수 있습니다. 하단은 한 줄 목록만 지원합니다.
+ 상단·하단을 탭으로 나눕니다. 순서는 태그 관리의 메인 태그와 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 않습니다. 하위 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다.
+
+
+ 저장 시 parent_id 오류가 나면 DB에 마이그레이션이 아직 없는 것입니다. 로컬에서 npm run db:migrate:dev로 017_navigation_hierarchy.sql을 적용하세요.